普通视图

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

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

2026年2月17日 17:36

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

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

作者:Alex MacArthur

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

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

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

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

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

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

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

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

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

可续充队列的设计思路

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

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

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

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

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

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

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

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

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

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

实现队列的可续充功能

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

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

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

    yield queue.shift();
  }
}

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

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

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

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

    yield queue.shift();
  }
}

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

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

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

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

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

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

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

      if (!queue.length) await promise;

      yield queue.shift();
    }
  }

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

  return {
    go,
    push,
  };
}

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

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

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

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

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

大功告成!接下来,我们将这个实现落地到实际开发场景中。

在 React 中落地可续充队列

正如前文所说,PicPerf 是一个图片优化、托管和缓存平台,支持用户上传多张图片进行处理。其界面采用了一个常见的交互模式:用户拖拽图片到指定区域,图片会按顺序逐步完成上传。 这正是可续充先进先出队列的适用场景:即便 “待上传” 的图片全部处理完毕,用户依然可以拖拽新的图片进来,上传流程会自动继续,队列会直接从新添加的文件开始处理。

React 中的基础实现方案

首先,我们尝试纯 React 的实现思路,充分利用 React 的状态与渲染生命周期,核心依赖两个状态:

  • files: UploadedFile[]:存储所有拖拽到界面的文件,每个文件自身维护一个状态:pending(待上传)、uploading(上传中)、completed(已完成)。
  • isUploading: boolean:标记当前是否正在上传文件,作为一个 “锁”,防止在已有上传任务执行时,启动新的上传循环。

这个组件的核心逻辑是监听files状态的变化,一旦有新文件加入,useEffect钩子就会触发上传流程;当一个文件上传完成后,将isUploading置为false,又会触发另一次useEffect执行,进而处理队列中的下一张图片。

以下是简化后的核心代码:

import { processUpload } from './wherever';

export default function MediaUpload() {
  const [files, setFiles] = useState([]);
  const [isUploading, setIsUploading] = useState(false);

  const updateFileStatus = useEffectEvent((id, status) => {
    setFiles((prev) =>
      prev.map((file) => (file.id === id ? { ...file, status } : file))
    );
  });

  useEffect(() => {
    // 已有上传任务执行时,直接返回
    if (isUploading) return;

    // 找到队列中第一个待上传的文件
    const nextPending = files.find((f) => f.status === 'pending');

    // 无待上传文件时,直接返回
    if (!nextPending) return;

    // 加锁,标记为上传中
    setIsUploading(true);
    updateFileStatus(nextPending.id, 'uploading');

    // 执行上传,完成后解锁并更新状态
    processUpload(nextPending).then(() => {
      updateFileStatus(nextPending.id, 'complete');
      setIsUploading(false);
    });
  }, [files, isUploading]);

  return <UploadComponent files={files} setFiles={setFiles} />;
}

在有文件正在上传时,用户依然可以添加新文件,新文件会被追加到队列末尾,等待后续逐个处理: 从 React 组件的设计角度来看,这种方案并非不可行,监听状态变化并做出相应响应也是很常见的实现方式。

但说实话,很难有人会觉得这个思路直观易懂。useEffect钩子的设计初衷是让组件与外部系统保持同步,而在这里,它却被用作了事件驱动的状态机调度工具,成了组件的核心行为逻辑,这显然偏离了其设计本意。

所以,我们不妨换掉这些useEffect钩子,用生成器实现的可续充队列来重构这个组件。

结合外部状态仓库实现

我们不再让 React 完全托管所有文件及其状态,而是将这些数据抽离到外部,从其他地方触发组件的重新渲染。这样一来,组件会变得更 “纯”,只专注于其核心职责 —— 渲染界面。

React 恰好提供了一个适配该场景的工具useSyncExternalStore。这个钩子能让组件监听外部管理的数据变化,组件的 “React 特性” 会适当让步,等待外部的指令,而非全权掌控所有状态。在本次实现中,这个 “外部状态仓库” 就是一个独立的模块,专门负责文件的处理逻辑。

useSyncExternalStore至少需要两个方法:一个用于监听数据变化(让 React 知道何时需要重新渲染组件),另一个用于返回数据的最新快照。以下是仓库的基础骨架:

// store.ts

let listeners: Function[] = [];
let files: UploadableFile[] = [];

// 必须返回一个取消监听的方法(供React内部使用)
export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 返回数据最新快照
export function getSnapshot() {
  return files;
}

接下来,我们补充实现所需的其他方法:

  • updateStatus():更新文件状态(待上传、上传中、已完成);
  • add():向队列中添加新文件;
  • process():启动并执行文件上传队列;
  • emitChange():通知 React 的监听器数据发生变化,触发组件重新渲染。

最终,状态仓库的完整代码如下:

// store.ts

import { buildQueue, processUpload } from './whatever';

let listeners: Function[] = [];
let files: any[] = [];
// 初始化可续充队列
const queue = buildQueue();

// 通知监听器,触发组件重渲染
function emitChange() {
  // 外部仓库的一个关键要点:数据变化时,必须返回新的引用
  files = [...queue.queue];

  for (let listener of listeners) {
    listener();
  }
}

// 更新文件状态
function updateStatus(file: any, status: string) {
  file.status = status;
  emitChange();
}

// 公共方法
export function getSnapshot() {
  return files;
}

export function subscribe(listener: Function) {
  listeners.push(listener);

  return () => {
    listeners = listeners.filter((l) => l !== listener);
  };
}

// 向队列添加新文件
export function add(newFiles: any[]) {
  queue.push(newFiles);
  emitChange();
}

// 执行文件上传流程
export async function process() {
  for await (const file of queue.go()) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }
}

此时,我们的 React 组件会变得异常简洁:

import { 
  add,
  process, 
  subscribe,
  getSnapshot
} from './store';

export default function MediaUpload() {
  // 监听外部仓库的数据变化
  const files = useSyncExternalStore(subscribe, getSnapshot);

  // 组件挂载时启动上传队列
  useEffect(() => {
    process();
  }, []);

  // 将文件数据和添加方法传递给子组件
  return <UploadComponent files={files} setFiles={add} />;
}

现在只剩一个细节需要完善:合理的清理逻辑。当组件卸载时,我们不希望还有未完成的上传任务在后台执行。因此,我们为仓库添加一个abort方法,强制终止生成器,并在组件的useEffect中执行清理:

// store.ts

// 其他代码不变

let iterator = null;

export async function process() {
  // 保存生成器迭代器的引用
  iterator = queue.go();

  for await (const file of iterator) {
    updateStatus(file, 'uploading');
    await processUpload(file);
    updateStatus(file, 'complete');
  }

  iterator = null;
}

// 强制终止生成器
export function abort() {
  return iterator?.return();
}
function MediaUpload() {
  const files = useSyncExternalStore(subscribe, getSnapshot);

  useEffect(() => {
    process();
    // 组件卸载时执行清理,终止上传队列
    return () => abort();
  }, []);

  return <UploadComponent files={files} setFiles={add} />;
}

需要说明的是,为了简化代码,这里做了一些大胆的假设:上传过程永远不会失败、process方法同一时间只会被调用一次、该仓库只有一个使用者。请忽略这些细节以及其他可能的疏漏,重点来看这种实现方案带来的诸多优势:

  1. 组件的行为不再依赖useEffect的反复触发,逻辑更清晰;
  2. 所有文件上传的业务逻辑都被抽离到独立模块中,与 React 解耦,可单独维护和复用;
  3. 终于有机会实际使用useSyncExternalStore这个 React 钩子;
  4. 我们成功在 React 中用异步生成器实现了一个可续充队列。

对有些人来说,这种方案可能比最初的纯 React 方案复杂得多,我完全理解这种感受。但不妨换个角度想:现在把代码写得复杂一点,就能多拖延一点时间,避免 AI 工具完全取代我们的工作、毁掉我们的职业未来,甚至 “收割” 我们的价值。带着这个目标去写代码吧!

当然,说句正经的:要让 AI 辅助开发持续发挥价值,需要人类帮助 AI 理解底层技术原语的设计目的、取舍原则和发展前景。掌握这些底层知识,永远有其不可替代的价值。

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

作者 SmalBox
2026年2月17日 14:59

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

在Unity URP渲染管线中,光照计算是创建逼真视觉效果的核心环节。Main Light Color节点作为Shader Graph中的重要组件,专门用于获取场景中主定向光源的颜色属性信息。这个节点为着色器艺术家和图形程序员提供了直接访问场景主要光源颜色数据的能力,使得材质能够对场景中最主要的光源做出精确响应。

Main Light Color节点在URP着色器开发中扮演着关键角色,它不仅仅返回简单的RGB颜色值,而是包含了完整的光照强度信息。这意味着开发者可以获取到经过Unity光照系统处理后的最终颜色结果,包括所有相关光照计算和后期处理效果的影响。这种直接访问方式大大简化了自定义光照模型的实现过程,使得即使是没有深厚图形编程背景的艺术家也能创建出专业级的光照响应材质。

在实时渲染中,主光源通常指场景中的主要定向光源,如太阳或月亮。Main Light Color节点正是针对这种关键光源设计的,它能够动态响应光照条件的变化,包括日夜循环、天气系统或游戏剧情驱动的光照变化。这种动态响应能力使得材质能够与游戏环境保持视觉一致性,创造出更加沉浸式的体验。

描述

Main Light Color节点是Shader Graph中专门用于获取场景主光源颜色信息的内置节点。该节点输出的颜色信息不仅包含基本的RGB色彩值,还整合了光源的亮度强度,形成了一个完整的颜色-强度组合数据。这种设计使得开发者可以直接使用该输出值参与光照计算,无需额外的强度调整或颜色处理。

从技术实现角度来看,Main Light Color节点在背后调用了URP渲染管线的内部函数,特别是GetMainLight()方法。这个方法会分析当前场景的光照设置,确定哪一个是主光源,并提取其所有相关属性。对于颜色信息,节点会综合考虑光源的基础颜色、强度值,以及任何可能影响最终输出的后期处理效果或光照修改组件。

在实际应用中,Main Light Color节点的输出值代表了主光源在当前渲染帧中对表面点可能产生的最大影响。这个值会根据光源的类型、设置和场景中的相对位置自动计算。对于定向光源,颜色和强度通常是恒定的(除非有动态修改),而对于其他类型的光源,可能会根据距离和角度有所不同。

该节点的一个关键特性是其输出的颜色值已经包含了亮度信息。这意味着一个强度为2的白色光源不会返回(1,1,1)的纯白色,而是会根据强度进行相应的亮度提升。这种设计决策使得节点输出可以直接用于光照计算,无需开发者手动将颜色与强度相乘,简化了着色器的构建过程。

Main Light Color节点在以下场景中特别有用:

  • 创建对动态光照条件响应的材质
  • 实现自定义的光照模型
  • 开发风格化的渲染效果
  • 构建与场景光照紧密交互的特效系统
  • 制作适应日夜循环的环境材质

技术实现细节

从底层实现来看,Main Light Color节点对应于HLSL代码中的_MainLightColor变量。在URP渲染管线中,这个变量在每帧开始时由渲染系统更新,确保着色器始终能够访问到最新的主光源信息。当场景中没有明确设置主光源时,系统会使用默认的光照设置,或者在某些情况下返回黑色(即无光照)。

节点的输出类型为Vector 3,分别对应颜色的R、G、B通道。每个通道的值范围通常是[0,∞),因为URP使用高动态范围光照计算。这意味着颜色值可以超过1,表示特别明亮的光源。在实际使用时,开发者可能需要根据具体需求对这些值进行适当的缩放或限制。

值得注意的是,Main Light Color节点获取的颜色已经考虑了光源的过滤器颜色(如果有的话)。例如,如果一个白色光源前面放置了红色的滤色片,那么节点返回的将是红色调的颜色值。这种完整性使得节点在各种复杂的照明场景中都能提供准确的结果。

性能考虑

Main Light Color节点是一个极其高效的操作,因为它只是读取一个已经计算好的全局着色器变量。与复杂的光照计算或纹理采样相比,它的性能开销可以忽略不计。这使得它非常适合用于移动平台或需要高性能的实时应用中。

在Shader Graph中使用该节点时,它不会增加额外的绘制调用或显著影响着色器的复杂度。然而,开发者应该注意,如果在一个着色器中多次使用该节点,最好将其输出存储在一个中间变量中,然后重复使用这个变量,而不是多次调用节点本身。这种优化实践有助于保持着色器的整洁和效率。

端口

Main Light Color节点的端口设计体现了其功能的专一性和高效性。作为一个输入输出结构简单的节点,它只包含一个输出端口,这种简约的设计反映了其单一职责原则——专注于提供主光源的颜色信息。

输出端口详解

Out - 输出方向 - Vector 3类型

Out端口是Main Light Color节点唯一的输出接口,负责传递主光源的完整颜色信息。这个Vector 3输出包含了以下关键信息:

  • R通道:红色分量,表示光源在红色频谱上的强度
  • G通道:绿色分量,表示光源在绿色频谱上的强度
  • B通道:蓝色分量,表示光源在蓝色频谱上的强度

重要的是,这些颜色分量已经包含了光源的亮度信息。这意味着一个强度为1的白色光源会返回近似(1,1,1)的值,而强度为2的白色光源会返回近似(2,2,2)的值。这种设计使得输出值可以直接用于光照计算,无需额外的强度乘法操作。

数据范围与特性

Main Light Color节点的输出值范围在理论上是无上限的,因为URP支持高动态范围渲染。在实际应用中,值的大小取决于光源的强度设置和颜色选择。以下是一些典型情况下的输出示例:

  • 默认白色定向光(强度1):约(1.0, 1.0, 1.0)
  • 明亮的白色太阳光(强度2):约(2.0, 2.0, 2.0)
  • 红色光源(强度1):约(1.0, 0.0, 0.0)
  • 蓝色光源(强度0.5):约(0.0, 0.0, 0.5)
  • 无主光源情况:约(0.0, 0.0, 0.0)

与其他节点的连接方式

Out端口的Vector 3输出可以与多种其他Shader Graph节点连接,实现复杂的光照效果:

与颜色操作节点连接

  • Multiply节点连接:调整光照颜色的强度或应用色调映射
  • Add节点连接:创建光照叠加效果
  • Lerp节点连接:在不同光照颜色间平滑过渡
  • Split节点连接:分离RGB通道进行独立处理

与光照计算节点结合

  • Dot Product节点结合:计算兰伯特光照
  • Normalize节点结合:准备光照方向计算
  • Power节点结合:实现更复杂的光照衰减

与纹理采样结合

  • Sample Texture 2D节点输出相乘:实现纹理受光照影响的效果
  • Texture Coordinates节点结合:创建基于光照的UV动画

实际应用示例

以下是一个基本的光照计算示例,展示如何使用Main Light Color节点的Out端口:

Main Light Color [Out] → Multiply [A]
Normal Vector → Dot Product [A]
Light Direction → Dot Product [B]
Dot Product [Out] → Multiply [B]

Multiply [Out] → Base Color [Base Map]

在这个示例中,Main Light Color的输出与兰伯特系数(通过法线与光方向的点积计算)相乘,最终结果用作基础颜色的调制因子。这种连接方式创建了基本的漫反射光照效果。

高级用法

对于更复杂的材质效果,开发者可以将Main Light Color的输出与其他高级节点结合:

镜面反射计算

Main Light Color → Multiply → Specular Output

自发光效果

Main Light Color → Add → Emission Input

阴影处理

Main Light Color → Multiply (with Shadow Attenuation) → Final Color

性能优化建议

虽然Main Light Color节点本身性能开销很小,但在复杂着色器中的使用方式会影响整体性能:

  • 尽量避免在着色器的多个位置重复调用Main Light Color节点,而是将其输出存储到变量中重复使用
  • 当只需要单通道信息时,考虑使用Split节点分离出所需通道,而不是处理完整的Vector 3
  • 在不需要HDR效果的场景中,可以使用Clamp节点将输出值限制在[0,1]范围内,这可能在某些硬件上提供轻微的性能提升

平台兼容性

Main Light Color节点的Out端口在所有支持URP的平台上都有相同的行为,包括:

  • Windows、MacOS、Linux
  • iOS和Android移动设备
  • 主流游戏主机平台
  • WebGL和XR设备

这种跨平台的一致性确保了使用Main Light Color节点的着色器可以在不同的目标平台上提供可预测的视觉效果,大大简化了多平台开发的复杂度。


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

2026 春晚魔术大揭秘:作为程序员,分分钟复刻一个(附源码)

2026年2月17日 14:14

大家好,我是 Sunday。

昨晚的 2026 年春晚上的魔术【惊喜定格】大家看了吗?

说实话,作为一名资深的前端开发者,我对那些歌舞节目的兴趣一般,但每年的魔术环节我必看。不是为了看奇迹,而是为了:找 Bug 😂。

今年的魔术特别有意思:

魔术师拿出一个手机计算器,让全场观众参与,又是随机想数字,又是乱按屏幕,最后算出来的结果,竟然精准地命中了 当前的年、月、日、时、分

我老婆说:“哇哦,好厉害啊~”

不过我是越看越不对,这玩意怎么感觉像是个 写死的 JS 脚本啊?


其实,这个魔术并不是 2026 春晚的首创。

早在去年(2025年)底,武汉理工大学的迎新晚会 上就有这个魔术的雏形。

当时也是一样的套路:随机数字 + 观众乱按 = 预言时间。

而这个魔术的实现原理,就在魔术师手中的 计算器

魔术师手里那个所谓的“计算器”,压根就不是系统自带的。那是一个专门开发的 Web App 或者 Native App

所以,咱们今天大年初一不整虚的,直接打开 VS Code,从原理到代码,一比一复刻这个价值过亿流量的春晚魔术!

春晚魔术的实现原理

这个魔术的核心逻辑,可以拆解为两个部分:

  1. 数学逻辑(后端逻辑):逆向推导
  2. 交互逻辑(前端表现):输入幻觉

1. 数学逻辑:逆向推导

普通人的思维是:输入 A + 输入 B + 乱按的 C = 结果

但在代码里,逻辑是反过来的:目标结果(当前时间) - 输入 A - 输入 B = 必须填补的差值(Force Number)

比如:

  • 目标时间:2月16日 22点27分 -> 数字 2162227
  • 观众 A 输入1106
  • 观众 B 输入88396
  • 当前总和89502
  • 系统偷偷算的差值2162227 - 89502 = 2072725

接下来,魔术师要做的,就是让第三个观众,在以为自己是“随机乱按”的情况下,把 2072725 这个数字“按”出来。

2. 交互逻辑:输入幻觉

这是整个魔术最精彩,也是前端最能发挥的地方。

魔术师会说:“来,大家随便按计算器,越乱越好。”

观众以为按 9 屏幕就会显示 9,按 5 就会显示 5

大错特错!

在这个 App 进入“魔术模式”后,键盘事件已经被 e.preventDefault() 拦截了。无论你按哪个数字键,屏幕上只会依次显示程序预设好的那个 差值字符串

  • 差值是 2072725
  • 你按“9”,代码输出 2
  • 你按“1”,代码输出 0
  • 你按“任意键”,代码输出 7...

现在知道 为什么魔术师要把屏幕翻过来了吧。就是为了不让大家看到用户真实输入的是什么。

实现源码

原理讲通了,咱们直接上代码,

  • 第一步:界面布局(Tailwind 真的香)

作为一名前端,UI 的还原度决定了魔术的可信度。我用了 Tailwind CSS 来复刻 iOS/小米计算器的风格。

<div class="grid grid-cols-4 gap-4">
    <button @click="appendNum('7')" class="...">7</button>
    <button @click="appendNum('8')" class="...">8</button>
    <button @click="calculate" class="btn-orange ...">=</button>
</div>

  • 第二步:设计“触发机关”

魔术师不能直接说:“我要变魔术了”。他需要一个隐蔽的开关。在这个代码里,我设计了一个 “三连击触发器”:当连续点击 3 次 = 号时,激活魔术模式。(当然,你可以不用这个触发,也并不影响)

// 状态定义
const equalClickCount = ref(0); // 统计等号点击次数
const isMagicMode = ref(false); // 魔术模式开关
const magicSequence = ref('');  // 算好的差值(剧本)

const calculate = () => {
    // ... 正常计算逻辑 ...
    
    // 触发检测
    equalClickCount.value++;
    if (equalClickCount.value === 3) {
        // 1. 获取目标:当前时间 (比如 2162227)
        const target = getMagicTarget(); 
        // 2. 获取现状:屏幕上的数字
        const currentSum = parseFloat(currentVal.value);
        // 3. 计算剧本:差值
        const diff = target - currentSum;
        
        if (diff > 0) {
            // 激活魔术模式!
            magicSequence.value = String(diff);
            isMagicMode.value = true;
            // 控制台偷偷告诉我们一声
            console.log(`🔒 锁定!目标:${target}, 差值:${diff}`);
        }
    }
}

  • 第三步:核心“欺骗”逻辑

这是最关键的 appendNum 函数。它根据当前是否处于 魔术模式 来决定是“听你的”还是“听我的”。

const appendNum = (num) => {
    // >>> 魔术模式:虽然你按了键,但我只输出剧本里的数字
    if (isMagicMode.value) {
        // 第一次按键时,清空屏幕,开始表演
        if (isFirstMagicInput.value) {
            currentVal.value = ''; 
            isFirstMagicInput.value = false;
        }

        // 依次吐出 magicSequence 里的字符
        if (magicIndex.value < magicSequence.value.length) {
            currentVal.value += magicSequence.value[magicIndex.value];
            magicIndex.value++;
            
            // 加点震动反馈,增加真实感(手机端体验极佳)
            if (navigator.vibrate) navigator.vibrate(50); 
        }
        return; 
    }
    
    // >>> 正常模式:该咋算咋算
    // ... 原有逻辑
};

使用方式:

  • 随机输入一个四位数
  • 随机输入一个五位数
  • 然后相加

  • 然后是 重点,连续按下三次等号,激活 魔术模式

  • 然后随便输入,无论你输入的是什么,最终都会显示出咱们计算好的值

最终得出当前的时间点 2 月 17 日 11 点 32 分!

写在最后

可能有人会觉得:“Sunday,你一个做技术教育的,搞这些花里胡哨的干嘛?”

其实,这和我们做项目是相通的。

我在 前端 + AI 训练营 里经常跟同学们强调一点:前端工程师的价值,不仅仅在于画页面,而在于“交互逻辑的实现”和“用户体验的掌控”。

这个魔术的完整 HTML 代码,我已经打包好了,大家可以直接在公众号【程序员Sunday】中回复【魔术】来获取源码

数组查找与判断:find / some / every / includes 的正确用法

作者 SuperEugene
2026年2月17日 09:31

今天是2026年2月17日农历正月初一,在2026 愿大家:身体健康无病痛,收入翻番钱包鼓! 代码 0 Error 0 Warning,需求一次过,上线零回滚!策马扬鞭,从小白进阶专家,新年一路 “狂飙”!🧧🐎 给大家拜年啦~

前言

前端里权限判断、表单校验、勾选状态,几乎都要判断「数组里有没有某个值」或「是否全部满足条件」。很多人习惯用 for 循环 + if 一把梭,或者 indexOf 判断,写多了既啰嗦又容易漏边界情况。
find / some / every / includes 这四个方法,可以把「查找 → 判断 → 校验」写得更短、更语义化,也更好处理边界情况。本文用 10 个常见场景,把日常该怎么选、为什么这么选、容易踩的坑讲清楚。

适合读者:

  • 会写 JS,但对 find/some/every/includes 用哪个、什么时候用有点模糊
  • 刚学 JS,希望一开始就养成清晰的数组判断写法
  • 有经验的前端,想统一团队里的权限/校验/状态判断写法

一、先搞清楚:find / some / every / includes 在干什么

这四个方法都不是黑魔法,本质是:在不动原数组的前提下,用一次遍历完成「查找 / 判断是否存在 / 判断是否全部满足」

方法 在干什么 返回值 什么时候停
find 找第一个符合条件的元素 找到的元素,找不到返回 undefined 找到第一个就停
some 判断是否至少有一个满足条件 truefalse 找到第一个就停(短路)
every 判断是否全部满足条件 truefalse 遇到第一个不满足就停(短路)
includes 判断数组里是否包含某个值(严格相等) truefalse 遍历完或找到就停
// 传统 for:意图分散,还要自己管 break
let found = null;
for (let i = 0; i < users.length; i++) {
  if (users[i].id === targetId) {
    found = users[i];
    break;
  }
}

// find:一眼看出「找第一个 id 匹配的」
const found = users.find((u) => u.id === targetId);

记住一点:能用语义化方法就不用循环,用 find/some/every/includes 把「要查什么、要判断什么」写清楚,比「怎么循环、怎么 break」更重要。

二、数组查找与判断的 10 个常用场景

假设接口返回的数据类似:

const users = [
  { id: 1, name: '张三', role: 'admin', status: 'active' },
  { id: 2, name: '李四', role: 'user', status: 'active' },
  { id: 3, name: '王五', role: 'user', status: 'inactive' },
];

const permissions = ['read', 'write', 'delete'];
const selectedIds = [1, 2];

下面 10 个写法,覆盖权限判断、表单校验、勾选状态等真实场景。

场景 1:找第一个符合条件的对象(find

const admin = users.find((user) => user.role === 'admin');
// { id: 1, name: '张三', role: 'admin', status: 'active' }

// 找不到返回 undefined
const superAdmin = users.find((user) => user.role === 'superAdmin');
// undefined

适用: 默认选中第一项、取第一个有效配置、根据 id 找对象等。
注意: find 找不到返回 undefined,后续解构或访问属性要处理,用 ?? 给默认值。

场景 2:判断是否至少有一个满足条件(some

const hasAdmin = users.some((user) => user.role === 'admin');
// true

const hasInactive = users.some((user) => user.status === 'inactive');
// true

适用: 权限判断「是否有任一管理员」、表单校验「是否有错误项」、状态判断「是否有未完成项」等。
注意: 空数组时 some 返回 false,业务上要结合「空列表算通过还是不算」处理。

场景 3:判断是否全部满足条件(every

const allActive = users.every((user) => user.status === 'active');
// false(因为有王五是 inactive)

const allHaveId = users.every((user) => user.id != null);
// true

适用: 表单校验「是否全部勾选」、权限判断「是否全部有权限」、状态判断「是否全部完成」等。
注意: 空数组时 every 返回 true(空真),业务上要结合「空列表算通过还是不算」处理。

场景 4:判断数组是否包含某个值(includes

const hasRead = permissions.includes('read');
// true

const hasExecute = permissions.includes('execute');
// false

适用: 简单值数组的包含判断、权限列表判断、标签列表判断等。
注意: includes 底层用 严格相等=== 做比较,这对「简单值(string / number / boolean)」很友好,但对「对象 / 数组」这类引用类型完全不适用,因为===比较的是内存地址而非内容。

场景 5:权限判断:是否有某个权限(some + includes

const userPermissions = ['read', 'write'];
const requiredPermission = 'delete';

const hasPermission = userPermissions.includes(requiredPermission);
// false

// 或判断多个权限中是否有任一
const requiredPermissions = ['delete', 'admin'];
const hasAnyPermission = requiredPermissions.some((perm) => 
  userPermissions.includes(perm)
);
// false

适用: 按钮权限控制、路由权限控制、功能权限判断等。
推荐: 简单值用 includes,复杂条件用 some + 回调。

场景 6:表单校验:是否全部必填项已填(every

const formFields = [
  { name: 'username', value: '张三', required: true },
  { name: 'email', value: '', required: true },
  { name: 'phone', value: '13800138000', required: false },
];

const allRequiredFilled = formFields
  .filter((field) => field.required)
  .every((field) => field.value.trim() !== '');
// false(email 为空)

适用: 表单提交前校验、批量操作前校验、多步骤流程校验等。
推荐:filter 筛出必填项,再用 every 判断是否全部有值。

场景 7:勾选状态:是否全部选中(every

const checkboxes = [
  { id: 1, checked: true },
  { id: 2, checked: true },
  { id: 3, checked: false },
];

const allChecked = checkboxes.every((item) => item.checked);
// false

const hasChecked = checkboxes.some((item) => item.checked);
// true

适用: 全选/反选功能、批量操作按钮状态、表格多选状态等。
推荐: every 判断全选,some 判断是否有选中项。

场景 8:找第一个并给默认值(find+ ??

const defaultUser = users.find((user) => user.role === 'admin') ?? {
  id: 0,
  name: '默认用户',
  role: 'guest',
};

适用: 默认选中第一项、取第一个有效配置、兜底默认值等。
注意: find 找不到返回 undefined,用 ?? 可以统一成默认对象,避免后面解构报错。

场景 9:对象数组是否包含某个 id(some

const targetId = 2;
const exists = users.some((user) => user.id === targetId);
// true

// 或判断多个 id 中是否有任一存在
const targetIds = [2, 5];
const hasAny = targetIds.some((id) => users.some((user) => user.id === id));
// true(2 存在)

适用: 判断选中项是否在列表里、判断 id 是否已存在、去重前判断等。
注意: 对象数组不能用 includes,要用 some + 条件判断。

场景 10:组合判断:全部满足 A 且至少一个满足 B(every +some

const allActive = users.every((user) => user.status === 'active');
const hasAdmin = users.some((user) => user.role === 'admin');

// 业务逻辑:全部激活 且 有管理员
const canOperate = allActive && hasAdmin;
// false(因为有 inactive 的)

适用: 复杂业务规则判断、多条件组合校验、权限组合判断等。
推荐: 把每个条件拆成变量,用名字表达「这一步在判断什么」,可读性和调试都会好很多。

三、容易踩的坑

1. find 找不到返回 undefined,直接解构会报错

const user = users.find((u) => u.id === 999);
const { name } = user; // TypeError: Cannot read property 'name' of undefined

正确:?? 给默认值,或先判断再解构。

const user = users.find((u) => u.id === 999) ?? { name: '未知' };
// 或
const user = users.find((u) => u.id === 999);
if (user) {
  const { name } = user;
}

2. 空数组时 every 返回 truesome 返回 false

[].every((x) => x > 0); // true(空真)
[].some((x) => x > 0);  // false

业务上要结合「空列表算通过还是不算」处理。例如表单校验,空列表可能应该算「未填写」而不是「通过」。

const fields = [];
const allFilled = fields.length > 0 && fields.every((f) => f.value);
// 先判断长度,再 every

3. includes 只能判断简单值,对象数组要用 some

const users = [{ id: 1 }, { id: 2 }];
users.includes({ id: 1 }); // false(对象引用不同)

// 正确:用 some + 条件判断
users.some((user) => user.id === 1); // true

4. findfilter 的区别:find 只找第一个,filter 找全部

const firstAdmin = users.find((u) => u.role === 'admin');
// 返回第一个对象或 undefined

const allAdmins = users.filter((u) => u.role === 'admin');
// 返回数组,可能为空数组 []

要「第一个」用 find,要「全部」用 filter,别混用。

5. someevery 的短路特性:找到就停

const users = [
  { id: 1, role: 'admin' },
  { id: 2, role: 'user' },
  { id: 3, role: 'admin' },
];

// some:找到第一个 admin 就停,不会继续遍历
users.some((u) => {
  console.log(u.id); // 只打印 1
  return u.role === 'admin';
});

// every:遇到第一个不是 admin 就停
users.every((u) => {
  console.log(u.id); // 打印 1, 2(遇到 user 就停)
  return u.role === 'admin';
});

性能上这是好事,但如果有副作用(如打印、修改外部变量),要注意只执行到第一个匹配项。

四、实战推荐写法模板

权限判断(是否有某个权限):

const userPermissions = response?.data?.permissions ?? [];
const canDelete = userPermissions.includes('delete');

// 或判断多个权限中是否有任一
const canManage = ['delete', 'admin'].some((perm) => 
  userPermissions.includes(perm)
);

表单校验(是否全部必填项已填):

const fields = formData?.fields ?? [];
const isValid = fields
  .filter((field) => field.required)
  .every((field) => field.value?.trim() !== '');

// 或更严格的校验
const isValid = fields.length > 0 && 
  fields.filter((f) => f.required).every((f) => f.value?.trim() !== '');

勾选状态(全选/部分选中):

const items = tableData ?? [];
const allChecked = items.length > 0 && items.every((item) => item.checked);
const hasChecked = items.some((item) => item.checked);

// 全选按钮状态
const selectAllDisabled = items.length === 0;
const selectAllChecked = allChecked;

找第一个并给默认值:

const defaultItem = (response?.data?.list ?? []).find(
  (item) => item.isDefault
) ?? {
  id: 0,
  name: '默认选项',
  value: '',
};

对象数组是否包含某个 id:

const selectedIds = [1, 2, 3];
const targetId = 2;
const isSelected = selectedIds.includes(targetId);

// 对象数组
const users = response?.data?.users ?? [];
const targetId = 2;
const exists = users.some((user) => user.id === targetId);

五、小结

场景 推荐写法 返回值
找第一个符合条件的对象 list.find(item => ...) 对象或 undefined
判断是否至少有一个满足 list.some(item => ...) truefalse
判断是否全部满足 list.every(item => ...) truefalse
判断是否包含某个值(简单值) list.includes(value) truefalse
找第一个并给默认值 list.find(...) ?? 默认值 对象或默认值
对象数组是否包含某个 id list.some(item => item.id === id) truefalse
表单校验:全部必填已填 list.filter(...).every(...) truefalse
勾选状态:全部选中 list.every(item => item.checked) truefalse

记住:find 负责「找」,some 负责「至少一个」,every 负责「全部」,includes 负责「简单值包含」。日常写权限、校验、状态判断时,先想清楚是要找对象、判断存在、判断全部,还是简单值包含,再选方法,代码会干净很多,也少踩坑。

特别提醒:

  • find 找不到返回 undefined,记得用 ?? 给默认值
  • 空数组时 everytruesomefalse,业务上要结合长度判断
  • 对象数组不能用 includes,要用 some + 条件判断

文章到这里结束。如果你日常写权限判断、表单校验、勾选状态时经常纠结用哪个方法,希望这篇能帮你定个型。

以上就是本次的学习分享,欢迎大家在评论区讨论指正,与大家共勉。

我是 Eugene,你的电子学友。

如果文章对你有帮助,别忘了点赞、收藏、加关注,你的认可是我持续输出的最大动力~

Vue3 源码解析系列 1:从 Debugger 视角读 Vue

作者 孙笑川_
2026年2月17日 03:03

引言

直接把源码当黑盒,或者干巴巴从头读到尾,几乎读不下去。更高效的方式是把源码当作“正在运行的程序”,用断点一层层摸清主流程。

这一篇记录我用经典 markdown.html 示例,跟踪 createApp -> mount -> render 的阅读路径。

初期准备

git clone https://github.com/vuejs/core.git
cd core
pnpm install
pnpm run dev # 生成 dev 版本 Vue

# 用浏览器打开示例
open packages/vue/examples/classic/markdown.html

入口断点:createApp

createApp 开始是最稳定的入口,它是 app 创建的第一站。

createApp 断点

渲染对比:从“看见结果”到“找到入口”

先把渲染结果和源码入口对齐,这样断点才更有目标感。

渲染对比

mount 打断点

mount 是渲染真正开始的地方,后面会进入 renderpatch

mount 断点

主流程:props / data / computed / watch

这个阶段会完成 Options API 的初始化,包括 data 绑定、computed 计算、watch 监听等。

主流程

data 绑定:把 data 暴露到 ctx

data() 返回对象后,会被转成响应式,并在 dev 模式下挂到 ctx 以便访问。

data 绑定

for (const key in data) {
checkDuplicateProperties!(OptionTypes.DATA, key)
// expose data on ctx during dev
if (!isReservedPrefix(key[0])) {
Object.defineProperty(ctx, key, {
configurable: true,
enumerable: true,
get: () => data[key],
set: NOOP,
})
}
}

computed:依赖收集与访问触发

依赖收集断点

computed 依赖收集

访问触发断点

computed 访问触发

在模板里出现:

<div v-html="compiledMarkdown"></div>

就会在渲染时读取 compiledMarkdown,触发 computed 的 getter。完整流程可以拆成:

  1. 读取 computedOptions
  2. 为每个 computed 添加 getter / setter
  3. 模板渲染时访问 compiledMarkdown
  4. 触发 getter,开始依赖追踪

一句话总结:把一个 getter(或 get/set)包装成带缓存的响应式 ref,并在依赖变化时标记为脏。

export function computed(getterOrOptions, debugOptions, isSSR = false) {
// 1. 解析 getter / setter
let getter, setter
if (isFunction(getterOrOptions)) {
getter = getterOrOptions
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}

// 2. 创建 ComputedRefImpl 实例
const cRef = new ComputedRefImpl(getter, setter, isSSR)

// 3. dev 环境下注入调试钩子
if (__DEV__ && debugOptions && !isSSR) {
cRef.onTrack = debugOptions.onTrack
cRef.onTrigger = debugOptions.onTrigger
}

// 4. 返回一个 ref(带 value getter/setter)
return cRef
}

生命周期注册

生命周期函数在这里统一注册,方便后续统一触发。

lifecycle 注册

registerLifecycleHook(onBeforeMount, beforeMount)
registerLifecycleHook(onMounted, mounted)
registerLifecycleHook(onBeforeUpdate, beforeUpdate)
registerLifecycleHook(onUpdated, updated)
registerLifecycleHook(onActivated, activated)
registerLifecycleHook(onDeactivated, deactivated)
registerLifecycleHook(onErrorCaptured, errorCaptured)
registerLifecycleHook(onRenderTracked, renderTracked)
registerLifecycleHook(onRenderTriggered, renderTriggered)
registerLifecycleHook(onBeforeUnmount, beforeUnmount)
registerLifecycleHook(onUnmounted, unmounted)
registerLifecycleHook(onServerPrefetch, serverPrefetch)

小结

这一篇先把路径跑通:createApp -> mount -> render -> patch -> Options 初始化。后面再深入 patch 和响应式系统时,你会发现思路完全一致:

  1. 找入口
  2. 断点追踪
  3. 明确数据流与调用链

下一篇我会继续浏览更新渲染源码。

【2】 Zensical配置详解

作者 Wcowin
2026年2月16日 23:41

zensical.toml 配置详解

全面了解 Zensical 的配置选项

image.png

Zensical 官方网站: zensical.org/
Zensical 官方文档: zensical.org/docs/
我写的Zensical教程:wcowin.work/Zensical-Ch…

配置文件格式

Zensical 项目通过 zensical.toml 文件进行配置。如果你使用 zensical new 命令创建项目,该文件会自动生成,并包含带注释的示例配置。

为什么选择 TOML?

TOML 文件格式 专门设计为易于扫描和理解。我们选择 TOML 而不是 YAML,因为它避免了 YAML 的一些问题:

  • YAML 使用缩进表示结构,这使得缩进错误特别容易出现且难以定位。在 TOML 中,空白主要是样式选择。
  • 在 YAML 中,值不需要转义,这可能导致歧义,例如 no 可能被解释为字符串或布尔值。TOML 要求所有字符串都要加引号。

从 MkDocs 过渡

为了便于从 Material for MkDocs 过渡,Zensical 可以原生读取 mkdocs.yml 配置文件。但是,我们建议新项目使用 zensical.toml 文件。

!!! info "配置文件支持" 由于 Zensical 是由 Material for MkDocs 的创建者构建的,我们支持通过 mkdocs.yml 文件进行配置,作为过渡机制,使现有项目能够平滑迁移到 Zensical。对 mkdocs.yml 的支持将始终保持,但最终会移出核心。

项目作用域

zensical.toml 配置以声明项目作用域的行开始:

[project]

目前,所有设置都包含在此作用域内。随着 Zensical 的发展,我们将引入额外的作用域,并在适当的地方将设置移出项目作用域。当然,我们会提供自动重构,因此无需手动迁移。

⚠️ 重要:配置顺序规则

在 TOML 配置文件中,配置顺序非常重要。必须遵循以下规则:

正确的配置顺序

  1. 先声明父表 [project]
  2. 然后配置所有直接属于 [project] 的键值对
    • site_name, site_url, site_description 等基本信息
    • repo_url, repo_name, edit_uri 等仓库配置
    • extra_javascript, extra_css 等额外资源
    • nav 导航配置
    • 其他所有直接属于 [project] 的配置
  3. 最后才声明子表
    • [project.theme] - 主题配置
    • [project.extra] - 额外配置
    • [project.plugins.xxx] - 插件配置
    • [project.markdown_extensions] - Markdown 扩展配置

为什么顺序很重要?

在 TOML 中,一旦声明了子表(如 [project.theme]),当前作用域就从 [project] 变成了 [project.theme]。之后的所有键值对都属于最后声明的表。

不能在声明子表后再回到父表添加键!

正确示例

[project]
# ✅ 所有父表的配置都在这里
site_name = "我的网站"
site_url = "https://example.com"
repo_url = "https://github.com/user/repo"
extra_javascript = ["script.js"]
extra_css = ["style.css"]
nav = [
    { "主页" = "index.md" },
]

# ✅ 父表配置完成后,才声明子表
[project.theme]
variant = "modern"
language = "zh"

[project.extra]
generator = true

[project.plugins.blog]
post_date_format = "full"

❌ 错误示例

[project]
site_name = "我的网站"

[project.theme]
variant = "modern"

# ❌ 错误!不能在子表之后回到父表添加键
site_url = "https://example.com"  # 这会导致解析错误!

常见错误

  1. 在子表后添加父表配置 - 会导致 TOML 解析错误
  2. 子表声明顺序混乱 - 虽然不会报错,但会让配置文件难以阅读和维护
  3. 忘记关闭父表配置 - 在声明子表前,确保所有父表配置都已完成

!!! warning "配置顺序错误会导致解析失败" 如果配置顺序不正确,Zensical 可能无法正确解析配置文件,导致构建失败。请务必遵循上述顺序规则。

主题变体

Zensical 提供两种主题变体:modern(现代)和 classic(经典),默认为 modern。classic 主题完全匹配 Material for MkDocs 的外观和感觉,而 modern 主题提供全新的设计。

如果你来自 Material for MkDocs 并希望保持其外观,或基于其外观自定义网站,可以切换到 classic 主题变体:

=== "zensical.toml"

```toml
[project.theme]
variant = "classic"
```

=== "mkdocs.yml"

```yaml
theme:
  variant: classic
```

!!! tip "自定义提示" Zensical 的 HTML 结构在两种主题变体中都与 Material for MkDocs 匹配。这意味着你现有的 CSS 和 JavaScript 自定义应该可以在任一主题变体中工作。

基础设置

让我们从最基础的配置开始,逐步构建一个完整的配置文件。

site_name

必需设置 - 提供网站名称,将显示在浏览器标签页、页面标题和导航栏中。

=== "zensical.toml"

```toml
[project]
site_name = "我的 Zensical 项目"
```

=== "mkdocs.yml"

```yaml
site_name: 我的 Zensical 项目
```

实际效果:

  • 浏览器标签页显示:我的 Zensical 项目
  • 页面标题显示:我的 Zensical 项目 - 页面名称
  • 导航栏左上角显示:我的 Zensical 项目

!!! note "关于 site_name" site_name 目前是必需的,因为 Zensical 替换的静态网站生成器 MkDocs 需要它。我们计划在未来版本中使此设置可选。

site_url

强烈推荐 - 网站的完整 URL,包括协议(http:// 或 https://)和域名。

=== "zensical.toml"

```toml
[project]
site_name = "我的 Zensical 项目"
site_url = "https://example.com"
```

=== "mkdocs.yml"

```yaml
site_name: 我的 Zensical 项目
site_url: https://example.com
```

为什么需要 site_url?

site_url 是以下功能的前提:

  1. 即时导航 - 需要知道网站的完整 URL 才能正确工作
  2. 即时预览 - 预览功能依赖正确的 URL
  3. 自定义错误页面 - 404 页面需要知道网站 URL
  4. RSS 订阅 - RSS 链接需要完整的 URL
  5. 社交分享 - 分享功能需要正确的 URL

!!! warning "重要" 如果使用即时导航功能,site_url必需的,否则即时导航将无法正常工作。

示例:

# 本地开发
site_url = "http://localhost:8000"

# GitHub Pages
site_url = "https://username.github.io"

# 自定义域名
site_url = "https://example.com"

site_description

可选 - 网站的描述,用于 SEO 和社交媒体分享。

=== "zensical.toml"

```toml
[project]
site_name = "我的 Zensical 项目"
site_url = "https://example.com"
site_description = "一个使用 Zensical 构建的文档网站"
```

=== "mkdocs.yml"

```yaml
site_name: 我的 Zensical 项目
site_url: https://example.com
site_description: 一个使用 Zensical 构建的文档网站
```

实际效果:

  • 在 HTML <meta name="description"> 标签中
  • 社交媒体分享时显示
  • 搜索引擎结果中可能显示

!!! tip "SEO 建议" 建议设置一个简洁、有吸引力的描述(50-160 个字符),有助于提高搜索引擎排名。

site_author

可选 - 网站作者名称。

=== "zensical.toml"

```toml
[project]
site_name = "我的 Zensical 项目"
site_author = "张三"
```

=== "mkdocs.yml"

```yaml
site_name: 我的 Zensical 项目
site_author: 张三
```

实际效果:

  • 在 HTML <meta name="author"> 标签中
  • 页脚可能显示作者信息(取决于主题配置)

copyright

可选 - 版权声明,显示在页面页脚。

=== "zensical.toml"

```toml
[project]
copyright = "Copyright &copy; 2025 张三"
```

=== "mkdocs.yml"

```yaml
copyright: "Copyright &copy; 2025 张三"
```

实际效果:

  • 显示在页面左下角页脚
  • 支持 HTML 标签(如 &copy; 显示为 ©)

示例:

# 纯文本
copyright = "Copyright 2025 张三"

# HTML 格式
copyright = "Copyright &copy; 2025 张三"

# 多行(使用多行字符串)
copyright = """
Copyright &copy; 2025 张三
All Rights Reserved
"""

docs_dir 和 site_dir

可选 - 文档目录和输出目录配置。

=== "zensical.toml"

```toml
[project]
docs_dir = "docs"      # 文档目录,默认:docs
site_dir = "site"      # 输出目录,默认:site
```

=== "mkdocs.yml"

```yaml
docs_dir: docs
site_dir: site
```

说明:

  • docs_dir:存放 Markdown 源文件的目录
  • site_dir:构建后生成的静态网站文件目录

!!! tip "目录结构示例" 项目根目录/ ├── docs/ # 源文件目录(docs_dir) │ ├── index.md │ └── blog/ ├── site/ # 输出目录(site_dir,运行 build 后生成) │ ├── index.html │ └── assets/ └── zensical.toml

完整的基础配置示例

以下是一个完整的基础配置示例,包含了所有推荐的基础设置:

[project]
# ===== 必需配置 =====
site_name = "我的 Zensical 项目"

# ===== 强烈推荐 =====
site_url = "https://example.com"  # 即时导航等功能需要

# ===== 推荐配置 =====
site_description = "一个使用 Zensical 构建的文档网站"
site_author = "张三"
copyright = "Copyright &copy; 2025 张三"

# ===== 目录配置(可选,有默认值)=====
docs_dir = "docs"      # 文档目录,默认:docs
site_dir = "site"      # 输出目录,默认:site
use_directory_urls = true  # 使用目录形式的 URL,默认:true

!!! tip "验证配置" 配置完成后,运行以下命令验证:

```bash
# 启动开发服务器
zensical serve

# 检查配置是否正确
zensical build
```

如果配置有误,会显示具体的错误信息。

extra

extra 配置选项用于存储模板使用的任意键值对。如果你覆盖模板,可以使用这些值来自定义行为。

=== "zensical.toml"

```toml
[project.extra]
key = "value"
analytics = "UA-XXXXXXXX-X"
```

=== "mkdocs.yml"

```yaml
extra:
  key: value
  analytics: UA-XXXXXXXX-X
```

use_directory_urls

控制文档网站的目录结构,从而控制用于链接到页面的 URL 格式。

=== "zensical.toml"

```toml
[project]
use_directory_urls = true  # 默认值
```

=== "mkdocs.yml"

```yaml
use_directory_urls: true
```

!!! info "离线使用" 在构建离线使用时,此选项会自动设置为 false,以便可以从本地文件系统浏览文档,而无需 Web 服务器。

主题配置

language

设置网站的语言。

=== "zensical.toml"

```toml
[project.theme]
language = "zh"  # 中文
```

=== "mkdocs.yml"

```yaml
theme:
  language: zh
```

features

启用或禁用主题功能。这是一个数组,可以同时启用多个功能。

配置示例:

[project.theme]
features = [
    # 导航相关
    "navigation.instant",           # 即时导航(推荐)
    "navigation.instant.prefetch",  # 预加载(推荐,提升性能)
    "navigation.tracking",          # 锚点跟踪
    "navigation.tabs",              # 导航标签
    "navigation.sections",          # 导航部分
    "navigation.top",               # 返回顶部按钮
    
    # 搜索相关
    "search.suggest",               # 搜索建议
    "search.highlight",             # 搜索高亮
    
    # 内容相关
    "content.code.copy",            # 代码复制按钮(推荐)
]

常用功能说明:

功能 说明 推荐
navigation.instant 即时导航,无需刷新页面 ✅ 强烈推荐
navigation.instant.prefetch 预加载链接,提升性能 ✅ 推荐
navigation.tracking URL 自动更新为当前锚点 ✅ 推荐
navigation.tabs 一级导航显示为顶部标签 ✅ 推荐
navigation.top 返回顶部按钮 ✅ 推荐
search.suggest 搜索时显示建议 ✅ 推荐
content.code.copy 代码块复制按钮 ✅ 强烈推荐

!!! warning "即时导航需要 site_url" 如果启用 navigation.instant,必须设置 site_url,否则即时导航将无法正常工作。

=== "zensical.toml"

```toml
[project.theme]
features = [
    "navigation.instant",
    "navigation.instant.prefetch",
    "navigation.tracking",
    "navigation.tabs",
    "navigation.top",
    "search.suggest",
    "content.code.copy",
]
```

=== "mkdocs.yml"

```yaml
theme:
  features:
    - navigation.instant
    - navigation.instant.prefetch
    - navigation.tracking
    - navigation.tabs
    - navigation.top
    - search.suggest
    - content.code.copy
```

palette

配置颜色主题,支持明暗模式切换。

基础配置示例:

# 日间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "indigo"    # 主色调
accent = "indigo"     # 强调色

# 夜间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "indigo"
accent = "indigo"

完整配置示例(包含自动模式):

# 自动模式(跟随系统)
[[project.theme.palette]]
media = "(prefers-color-scheme)"
toggle = { icon = "material/link", name = "关闭自动模式" }

# 日间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "indigo"
accent = "indigo"
toggle = { icon = "material/toggle-switch", name = "切换至夜间模式" }

# 夜间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "indigo"
accent = "indigo"
toggle = { icon = "material/toggle-switch-off-outline", name = "切换至日间模式" }

支持的主色调:

  • red, pink, purple, deep-purple
  • indigo(推荐), blue, light-blue, cyan
  • teal, green, light-green, lime
  • yellow, amber, orange, deep-orange
  • brown, grey, blue-grey, black, white

!!! tip "选择颜色" - indigoblue 是最常用的主色调 - primary 影响导航栏、链接等主要元素 - accent 影响按钮、高亮等强调元素

=== "zensical.toml"

```toml
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "indigo"
accent = "indigo"

[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "indigo"
accent = "indigo"
```

=== "mkdocs.yml"

```yaml
theme:
  palette:
    - scheme: default
      primary: indigo
      accent: indigo
    - scheme: slate
      primary: indigo
      accent: indigo
```

font

配置字体。

=== "zensical.toml"

```toml
[project.theme.font]
text = "Roboto"
code = "Roboto Mono"
```

=== "mkdocs.yml"

```yaml
theme:
  font:
    text: Roboto
    code: Roboto Mono
```

logo 和 favicon

设置网站 logo 和 favicon。

=== "zensical.toml"

```toml
[project.theme]
logo = "assets/logo.png"
favicon = "assets/favicon.png"
```

=== "mkdocs.yml"

```yaml
theme:
  logo: assets/logo.png
  favicon: assets/favicon.png
```

插件配置

博客插件

=== "zensical.toml"

```toml
[project.plugins.blog]
post_date_format = "full"
post_url_format = "{date}/{slug}"
post_readtime = true
post_readtime_words_per_minute = 265
draft = true
```

=== "mkdocs.yml"

```yaml
plugins:
  - blog:
      enabled: true
      blog_dir: blog
      post_date_format: full
      post_url_format: "{date}/{slug}"
      post_readtime: true
      post_readtime_words_per_minute: 265
      draft: true
```

搜索插件

=== "zensical.toml"

```toml
[project.plugins.search]
lang = ["zh", "en"]
separator = '[\s\-\.]+'  # 中文优化:'[\s\u200b\-]'
```

=== "mkdocs.yml"

```yaml
plugins:
  - search:
      enabled: true
      lang:
        - zh
        - en
      separator: '[\s\-\.]+'
```

标签插件

=== "zensical.toml"

```toml
[project.plugins.tags]
tags_file = "tags.md"
```

=== "mkdocs.yml"

```yaml
plugins:
  - tags:
      enabled: true
      tags_file: tags.md
```

导航配置

nav

定义网站的导航结构。

基本用法

=== "zensical.toml"

```toml
[project]
nav = [    { "主页" = "index.md" },    { "快速开始" = "quick-start.md" },    { "配置" = "configuration.md" },]
```

=== "mkdocs.yml"

```yaml
nav:
  - 主页: index.md
  - 快速开始: quick-start.md
  - 配置: configuration.md
```

实际效果:

  • 导航栏显示三个顶级菜单项
  • 点击后跳转到对应页面
嵌套导航(导航分组)

创建多层级的导航结构,将相关页面组织在一起:

=== "zensical.toml"

```toml
[project]
nav = [    { "主页" = "index.md" },    { "快速开始" = [        { "5 分钟快速开始" = "getting-started/quick-start.md" },        { "从 MkDocs 迁移" = "getting-started/migration.md" },    ] },
    { "核心教程" = [        { "配置详解" = "tutorials/configuration.md" },        { "主题定制" = "tutorials/theme-customization.md" },    ] },
]
```

=== "mkdocs.yml"

```yaml
nav:
  - 主页: index.md
  - 快速开始:
      - 5 分钟快速开始: getting-started/quick-start.md
      - 从 MkDocs 迁移: getting-started/migration.md
  - 核心教程:
      - 配置详解: tutorials/configuration.md
      - 主题定制: tutorials/theme-customization.md
```

实际效果:

  • "快速开始" 和 "核心教程" 显示为可展开的分组
  • 点击分组名称展开子菜单
  • 子菜单项点击后跳转到对应页面
外部链接

导航项也可以指向外部 URL,任何无法解析为 Markdown 文件的字符串都会被当作 URL 处理:

=== "zensical.toml"

```toml
[project]
nav = [    { "主页" = "index.md" },    { "GitHub 仓库" = "https://github.com/zensical/zensical" },    { "个人博客" = "https://wcowin.work/" },]
```

=== "mkdocs.yml"

```yaml
nav:
  - 主页: index.md
  - GitHub 仓库: https://github.com/zensical/zensical
  - 个人博客: https://wcowin.work/
```

实际效果:

  • 外部链接在新标签页中打开
  • 可以混合使用内部页面和外部链接
完整配置示例

本教程实际使用的完整导航配置:

[project]
nav = [
    { "主页" = "index.md" },
    { "快速开始" = [
        { "5 分钟快速开始" = "getting-started/quick-start.md" },
        { "从 MkDocs 迁移" = "getting-started/migration.md" },
    ] },
    { "核心教程" = [
        { "zensical.toml 配置详解" = "tutorials/configuration.md" },
        { "主题定制指南" = "tutorials/theme-customization.md" },
        { "Markdown 扩展使用" = "tutorials/markdown-extensions.md" },
        { "Zensical 博客系统完全指南" = "tutorials/blog-tutorial.md" },
    ] },
    { "插件系统" = [
        { "博客插件详解" = "blog/plugins/blog.md" },
        { "搜索插件配置" = "blog/plugins/search.md" },
        { "标签插件使用" = "blog/plugins/tags.md" },
        { "RSS 插件配置" = "blog/plugins/rss.md" },
    ] },
    { "部署指南" = [
        { "GitHub Pages 部署(推荐)" = "blog/deployment/github-pages.md" },
        { "Netlify 部署" = "blog/deployment/netlify.md" },
        { "GitLab Pages 部署" = "blog/deployment/gitlab-pages.md" },
        { "自托管部署" = "blog/deployment/self-hosted.md" },
    ] },
    { "高级主题" = [
        { "性能优化" = "blog/advanced/performance.md" },
        { "SEO 优化" = "blog/advanced/seo.md" },
        { "多语言支持" = "blog/advanced/i18n.md" },
        { "自定义 404 页面" = "blog/advanced/custom-404.md" },
        { "自定义字体" = "blog/advanced/custom-fonts.md" },
        { "添加评论系统" = "blog/advanced/comment-system.md" },
    ] },
    { "常见问题" = "faq.md" },
    { "案例展示" = "showcase.md" },
    { "关于" = "about.md" },
    { "个人博客" = "https://wcowin.work/" },
]

!!! tip "导航配置技巧" - 路径相对于 docs_dir:所有文件路径都相对于 docs 目录 - 自动提取标题:如果不指定标题,Zensical 会自动从文件中提取 - 嵌套层级:支持多层嵌套,但建议不超过 3 层以保持导航清晰 - 外部链接:URL 会在新标签页中打开,内部链接在当前页面打开 - 数组格式:使用 nav = [...] 格式,结构清晰,易于维护

Markdown 扩展

Zensical 支持丰富的 Markdown 扩展,这些扩展基于官方推荐配置,提供了强大的文档编写能力。

官方推荐配置(完整版)

以下配置是 Zensical 官方推荐的完整 Markdown 扩展配置,包含了所有常用功能:

=== "zensical.toml"

```toml
# ===== 基础扩展 =====
[project.markdown_extensions.abbr]          # 缩写支持
[project.markdown_extensions.admonition]    # 警告框(!!! note)
[project.markdown_extensions.attr_list]     # 属性列表
[project.markdown_extensions.def_list]      # 定义列表
[project.markdown_extensions.footnotes]     # 脚注支持
[project.markdown_extensions.md_in_html]    # HTML 中使用 Markdown
[project.markdown_extensions.toc]           # 目录生成
toc_depth = 3                               # 目录深度
permalink = true                            # 标题锚点链接

# ===== 数学公式支持 =====
[project.markdown_extensions."pymdownx.arithmatex"]
generic = true  # 使用 MathJax 渲染数学公式

# ===== 文本增强 =====
[project.markdown_extensions."pymdownx.betterem"]
smart_enable = "all"  # 智能斜体/粗体

[project.markdown_extensions."pymdownx.caret"]      # 上标 (^text^)
[project.markdown_extensions."pymdownx.mark"]      # 标记文本 (==text==)
[project.markdown_extensions."pymdownx.tilde"]     # 删除线 (~~text~~)

# ===== 交互元素 =====
[project.markdown_extensions."pymdownx.details"]   # 可折叠详情框
[project.markdown_extensions."pymdownx.tabbed"]    # 标签页
alternate_style = true
[project.markdown_extensions."pymdownx.tasklist"]  # 任务列表
custom_checkbox = true

# ===== 代码相关 =====
[project.markdown_extensions."pymdownx.highlight"]     # 代码高亮
[project.markdown_extensions."pymdownx.inlinehilite"] # 行内代码高亮
[project.markdown_extensions."pymdownx.superfences"]  # 代码块和 Mermaid

# ===== 其他功能 =====
[project.markdown_extensions."pymdownx.keys"]         # 键盘按键 (++ctrl+alt+del++)
[project.markdown_extensions."pymdownx.smartsymbols"]  # 智能符号转换
[project.markdown_extensions."pymdownx.emoji"]        # Emoji 表情
emoji_generator = "zensical.extensions.emoji.to_svg"
emoji_index = "zensical.extensions.emoji.twemoji"
```

=== "mkdocs.yml"

```yaml
markdown_extensions:
  # 基础扩展
  - abbr
  - admonition
  - attr_list
  - def_list
  - footnotes
  - md_in_html
  - toc:
      permalink: true
      toc_depth: 3
  
  # PyMdown 扩展
  - pymdownx.arithmatex:
      generic: true
  - pymdownx.betterem:
      smart_enable: all
  - pymdownx.caret
  - pymdownx.details
  - pymdownx.emoji:
      emoji_generator: zensical.extensions.emoji.to_svg
      emoji_index: zensical.extensions.emoji.twemoji
  - pymdownx.highlight
  - pymdownx.inlinehilite
  - pymdownx.keys
  - pymdownx.mark
  - pymdownx.smartsymbols
  - pymdownx.superfences
  - pymdownx.tabbed:
      alternate_style: true
  - pymdownx.tasklist:
      custom_checkbox: true
  - pymdownx.tilde
```

扩展功能说明

基础扩展
扩展 功能 示例
abbr 缩写支持 <abbr title="HyperText Markup Language">HTML</abbr>
admonition 警告框 !!! note "提示"
attr_list 属性列表 {: .class-name }
def_list 定义列表 术语 : 定义
footnotes 脚注 [^1][^1]: 脚注内容
md_in_html HTML 中使用 Markdown <div markdown="1">**粗体**</div>
toc 自动生成目录 自动生成页面目录
PyMdown 扩展
扩展 功能 示例
pymdownx.arithmatex 数学公式 $E=mc^2$$$\int_0^\infty$$
pymdownx.betterem 智能斜体/粗体 自动处理 *text***text**
pymdownx.caret 上标 ^text^text
pymdownx.details 可折叠详情 ??? note "点击展开"
pymdownx.emoji Emoji 表情 :smile: → 😄
pymdownx.highlight 代码高亮 语法高亮的代码块
pymdownx.inlinehilite 行内代码高亮 `code`
pymdownx.keys 键盘按键 ++ctrl+alt+del++
pymdownx.mark 标记文本 ==text==text
pymdownx.smartsymbols 智能符号 (c) → ©, (tm) → ™
pymdownx.superfences 代码块和 Mermaid 支持代码块和流程图
pymdownx.tabbed 标签页 === "标签1"
pymdownx.tasklist 任务列表 - [ ] 未完成 / - [x] 已完成
pymdownx.tilde 删除线 ~~text~~text

常用配置示例

最小配置(仅基础功能)
[project.markdown_extensions]
admonition = {}           # 警告框
attr_list = {}            # 属性列表
md_in_html = {}           # HTML 中使用 Markdown
tables = {}               # 表格支持
推荐配置(常用功能)
[project.markdown_extensions]
admonition = {}
attr_list = {}
md_in_html = {}
toc = { permalink = true, toc_depth = 3 }

[project.markdown_extensions."pymdownx.highlight"]
[project.markdown_extensions."pymdownx.superfences"]
[project.markdown_extensions."pymdownx.tabbed"]
alternate_style = true
[project.markdown_extensions."pymdownx.tasklist"]
custom_checkbox = true
[project.markdown_extensions."pymdownx.emoji"]
emoji_generator = "zensical.extensions.emoji.to_svg"
emoji_index = "zensical.extensions.emoji.twemoji"

实际使用示例

警告框(Admonition)
!!! note "提示"
    这是一个提示框

!!! warning "警告"
    这是一个警告框

!!! tip "技巧"
    这是一个技巧提示
代码高亮(Highlight)
```python
def hello():
    print("Hello, Zensical!")
```
标签页(Tabbed)
=== "Python"
    ```python
    print("Hello")
    ```

=== "JavaScript"
    ```javascript
    console.log("Hello");
    ```
任务列表(Tasklist)
- [x] 已完成的任务
- [ ] 未完成的任务
数学公式(Arithmatex)
行内公式:$E=mc^2$

块级公式:
$$
\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}
$$
Emoji 表情
:smile: :heart: :rocket: :thumbsup:

!!! tip "更多示例" 详细的使用示例和说明请参考 Markdown 扩展使用指南

完整配置示例

以下是一个完整的、生产环境可用的配置示例,包含了所有常用配置:

[project]
# ===== 基本信息 =====
site_name = "我的 Zensical 项目"
site_url = "https://example.com"
site_description = "一个使用 Zensical 构建的文档网站"
site_author = "张三"
copyright = "Copyright &copy; 2025 张三"

# ===== 目录配置 =====
docs_dir = "docs"
site_dir = "site"
use_directory_urls = true

# ===== 仓库配置 =====
repo_url = "https://github.com/username/repo"
repo_name = "repo"
edit_uri = "edit/main/docs"

# ===== 额外资源 =====
extra_javascript = [
    "javascripts/extra.js",
]
extra_css = [
    "stylesheets/extra.css",
]

# ===== 导航配置 =====
nav = [
    { "主页" = "index.md" },
    { "快速开始" = [
        { "5 分钟快速开始" = "getting-started/quick-start.md" },
        { "从 MkDocs 迁移" = "getting-started/migration.md" },
    ] },
    { "核心教程" = [
        { "配置详解" = "tutorials/configuration.md" },
        { "主题定制" = "tutorials/theme-customization.md" },
        { "Markdown 扩展" = "tutorials/markdown-extensions.md" },
        { "博客系统指南" = "tutorials/blog-tutorial.md" },
    ] },
    { "常见问题" = "faq.md" },
    { "个人博客" = "https://wcowin.work/" },
]

# ===== 主题配置 =====
[project.theme]
variant = "modern"
language = "zh"
logo = "assets/logo.svg"
favicon = "assets/favicon.png"

features = [
    "navigation.instant",
    "navigation.instant.prefetch",
    "navigation.tracking",
    "navigation.tabs",
    "navigation.sections",
    "navigation.top",
    "search.suggest",
    "search.highlight",
    "content.code.copy",
]

# 日间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: light)"
scheme = "default"
primary = "indigo"
accent = "indigo"

# 夜间模式
[[project.theme.palette]]
media = "(prefers-color-scheme: dark)"
scheme = "slate"
primary = "indigo"
accent = "indigo"

[project.theme.font]
text = "Roboto"
code = "Roboto Mono"

# ===== 插件配置 =====
[project.plugins.blog]
post_date_format = "full"
post_readtime = true
post_readtime_words_per_minute = 265
draft = true

[project.plugins.search]
lang = ["zh", "en"]
separator = '[\s\u200b\-]'

[project.plugins.tags]

# ===== Markdown 扩展配置 =====
[project.markdown_extensions.abbr]
[project.markdown_extensions.admonition]
[project.markdown_extensions.attr_list]
[project.markdown_extensions.def_list]
[project.markdown_extensions.footnotes]
[project.markdown_extensions.md_in_html]
[project.markdown_extensions.toc]
toc_depth = 3
permalink = true

[project.markdown_extensions."pymdownx.arithmatex"]
generic = true

[project.markdown_extensions."pymdownx.betterem"]
smart_enable = "all"

[project.markdown_extensions."pymdownx.caret"]
[project.markdown_extensions."pymdownx.details"]
[project.markdown_extensions."pymdownx.emoji"]
emoji_generator = "zensical.extensions.emoji.to_svg"
emoji_index = "zensical.extensions.emoji.twemoji"

[project.markdown_extensions."pymdownx.highlight"]
[project.markdown_extensions."pymdownx.inlinehilite"]
[project.markdown_extensions."pymdownx.keys"]
[project.markdown_extensions."pymdownx.mark"]
[project.markdown_extensions."pymdownx.smartsymbols"]
[project.markdown_extensions."pymdownx.superfences"]
[project.markdown_extensions."pymdownx.tabbed"]
alternate_style = true
[project.markdown_extensions."pymdownx.tasklist"]
custom_checkbox = true
[project.markdown_extensions."pymdownx.tilde"]

!!! tip "配置验证" 配置完成后,建议运行以下命令验证:

```bash
# 检查配置语法
zensical build

# 启动开发服务器查看效果
zensical serve
```

下一步


参考资料

Options API 与 Composition API 对照表

2026年2月16日 22:33

Options API 与 Composition API 对照表

学习目标

完成本章学习后,你将能够:

  • 理解Options API和Composition API的核心区别
  • 快速将Options API代码转换为Composition API
  • 根据项目需求选择合适的API风格
  • 理解两种API风格的优缺点和适用场景

前置知识

学习本章内容前,你需要掌握:

问题引入

实际场景

小李是一名前端开发者,公司的老项目使用Vue 2和Options API编写。现在公司决定将项目升级到Vue 3,并逐步迁移到Composition API。小李需要:

  1. 理解两种API的对应关系:如何将Options API的data、methods、computed等转换为Composition API?
  2. 保持功能一致性:迁移后的代码需要保持原有功能不变
  3. 利用新特性:在迁移过程中,如何利用Composition API的优势改进代码?
  4. 团队协作:如何让团队成员快速理解两种API的区别?

为什么需要这个对照表

Options API和Composition API是Vue提供的两种不同的组件编写方式:

  • Options API:Vue 2的传统写法,通过配置对象(data、methods、computed等)组织代码
  • Composition API:Vue 3引入的新写法,通过组合函数的方式组织代码,提供更好的逻辑复用和类型推导

这个对照表将帮助你:

  1. 快速找到Options API在Composition API中的对应写法
  2. 理解两种API风格的设计思想差异
  3. 顺利完成项目迁移和代码重构
  4. 在新项目中做出合适的技术选择

核心概念

概念1:API风格对比

Options API特点

Options API通过配置对象的方式组织代码,每个选项负责特定的功能:

export default {
  data() {
    return {
      // 响应式数据
    }
  },
  computed: {
    // 计算属性
  },
  methods: {
    // 方法
  },
  mounted() {
    // 生命周期钩子
  }
}

优点

  • 结构清晰,容易理解
  • 适合小型组件
  • 学习曲线平缓

缺点

  • 逻辑分散在不同选项中
  • 难以复用逻辑
  • TypeScript支持较弱
Composition API特点

Composition API通过组合函数的方式组织代码,相关逻辑可以放在一起:

<script setup>
import { ref, computed, onMounted } from 'vue';

// 所有逻辑都在setup中,可以按功能组织
const count = ref(0);
const doubled = computed(() => count.value * 2);

onMounted(() => {
  // 生命周期逻辑
});
</script>

优点

  • 逻辑复用更容易(组合式函数)
  • 更好的TypeScript支持
  • 更灵活的代码组织
  • 更好的tree-shaking

缺点

  • 学习曲线较陡
  • 需要理解ref和reactive的区别
  • 代码可能不如Options API结构化

概念2:核心API对照

下面是两种API风格的完整对照表:

完整对照表

1. 响应式数据

Options API Composition API 说明
data() ref() / reactive() 定义响应式数据

Options API示例:

<script>
export default {
  data() {
    return {
      count: 0,
      user: {
        name: '张三',
        age: 25
      }
    }
  }
}
</script>

Composition API示例:

<script setup>
import { ref, reactive } from 'vue';

// 使用ref定义基本类型
const count = ref(0);

// 使用reactive定义对象
const user = reactive({
  name: '张三',
  age: 25
});

// 注意:访问ref需要.value,reactive不需要
console.log(count.value); // 0
console.log(user.name);   // '张三'
</script>

2. 计算属性

Options API Composition API 说明
computed computed() 定义计算属性

Options API示例:

<script>
export default {
  data() {
    return {
      firstName: '张',
      lastName: '三'
    }
  },
  computed: {
    // 只读计算属性
    fullName() {
      return this.firstName + this.lastName;
    },
    // 可写计算属性
    reversedName: {
      get() {
        return this.lastName + this.firstName;
      },
      set(value) {
        this.lastName = value[0];
        this.firstName = value.slice(1);
      }
    }
  }
}
</script>

Composition API示例:

<script setup>
import { ref, computed } from 'vue';

const firstName = ref('张');
const lastName = ref('三');

// 只读计算属性
const fullName = computed(() => {
  return firstName.value + lastName.value;
});

// 可写计算属性
const reversedName = computed({
  get() {
    return lastName.value + firstName.value;
  },
  set(value) {
    lastName.value = value[0];
    firstName.value = value.slice(1);
  }
});
</script>

3. 方法

Options API Composition API 说明
methods 普通函数 定义方法

Options API示例:

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++;
    },
    decrement() {
      this.count--;
    },
    reset() {
      this.count = 0;
    }
  }
}
</script>

Composition API示例:

<script setup>
import { ref } from 'vue';

const count = ref(0);

// 直接定义函数,不需要methods选项
const increment = () => {
  count.value++;
};

const decrement = () => {
  count.value--;
};

const reset = () => {
  count.value = 0;
};
</script>

4. 侦听器

Options API Composition API 说明
watch watch() / watchEffect() 侦听数据变化

Options API示例:

<script>
export default {
  data() {
    return {
      question: '',
      answer: '请输入问题'
    }
  },
  watch: {
    // 简单侦听
    question(newValue, oldValue) {
      console.log(`问题从 "${oldValue}" 变为 "${newValue}"`);
      this.getAnswer();
    },
    // 深度侦听
    user: {
      handler(newValue, oldValue) {
        console.log('用户信息变化');
      },
      deep: true,
      immediate: true
    }
  },
  methods: {
    getAnswer() {
      this.answer = '正在思考...';
    }
  }
}
</script>

Composition API示例:

<script setup>
import { ref, watch, watchEffect } from 'vue';

const question = ref('');
const answer = ref('请输入问题');
const user = ref({ name: '张三', age: 25 });

// 使用watch侦听特定数据源
watch(question, (newValue, oldValue) => {
  console.log(`问题从 "${oldValue}" 变为 "${newValue}"`);
  getAnswer();
});

// 深度侦听对象
watch(user, (newValue, oldValue) => {
  console.log('用户信息变化');
}, {
  deep: true,
  immediate: true
});

// 使用watchEffect自动追踪依赖
watchEffect(() => {
  // 自动追踪question的变化
  console.log(`当前问题:${question.value}`);
});

const getAnswer = () => {
  answer.value = '正在思考...';
};
</script>

5. 生命周期钩子

Options API Composition API 说明
beforeCreate - 使用setup()替代
created - 使用setup()替代
beforeMount onBeforeMount() 挂载前
mounted onMounted() 挂载后
beforeUpdate onBeforeUpdate() 更新前
updated onUpdated() 更新后
beforeUnmount onBeforeUnmount() 卸载前
unmounted onUnmounted() 卸载后
errorCaptured onErrorCaptured() 错误捕获
activated onActivated() keep-alive激活
deactivated onDeactivated() keep-alive停用

Options API示例:

<script>
export default {
  data() {
    return {
      message: 'Hello'
    }
  },
  beforeCreate() {
    console.log('beforeCreate: 实例初始化之后');
  },
  created() {
    console.log('created: 实例创建完成');
    // 可以访问this.message
  },
  beforeMount() {
    console.log('beforeMount: 挂载开始之前');
  },
  mounted() {
    console.log('mounted: 挂载完成');
    // 可以访问DOM
  },
  beforeUpdate() {
    console.log('beforeUpdate: 数据更新前');
  },
  updated() {
    console.log('updated: 数据更新后');
  },
  beforeUnmount() {
    console.log('beforeUnmount: 卸载前');
  },
  unmounted() {
    console.log('unmounted: 卸载完成');
    // 清理定时器、事件监听等
  }
}
</script>

Composition API示例:

<script setup>
import { 
  ref, 
  onBeforeMount, 
  onMounted, 
  onBeforeUpdate, 
  onUpdated,
  onBeforeUnmount,
  onUnmounted
} from 'vue';

const message = ref('Hello');

// setup()本身就相当于beforeCreate和created
console.log('setup执行,相当于created');

onBeforeMount(() => {
  console.log('onBeforeMount: 挂载开始之前');
});

onMounted(() => {
  console.log('onMounted: 挂载完成');
  // 可以访问DOM
});

onBeforeUpdate(() => {
  console.log('onBeforeUpdate: 数据更新前');
});

onUpdated(() => {
  console.log('onUpdated: 数据更新后');
});

onBeforeUnmount(() => {
  console.log('onBeforeUnmount: 卸载前');
});

onUnmounted(() => {
  console.log('onUnmounted: 卸载完成');
  // 清理定时器、事件监听等
});
</script>

6. Props

Options API Composition API 说明
props defineProps() 定义组件属性

Options API示例:

<script>
export default {
  props: {
    // 简单声明
    title: String,
    // 详细声明
    count: {
      type: Number,
      required: true,
      default: 0,
      validator(value) {
        return value >= 0;
      }
    },
    user: {
      type: Object,
      default: () => ({ name: '匿名' })
    }
  },
  mounted() {
    // 通过this访问props
    console.log(this.title);
    console.log(this.count);
  }
}
</script>

Composition API示例:

<script setup>
import { computed } from 'vue';

// 简单声明
// const props = defineProps(['title', 'count']);

// 详细声明(推荐)
const props = defineProps({
  title: String,
  count: {
    type: Number,
    required: true,
    default: 0,
    validator(value) {
      return value >= 0;
    }
  },
  user: {
    type: Object,
    default: () => ({ name: '匿名' })
  }
});

// TypeScript类型声明(更推荐)
// const props = defineProps<{
//   title?: string;
//   count: number;
//   user?: { name: string };
// }>();

// 直接访问props,不需要this
console.log(props.title);
console.log(props.count);

// props是响应式的,可以在computed中使用
const doubledCount = computed(() => props.count * 2);
</script>

7. Emits(事件)

Options API Composition API 说明
emits + $emit defineEmits() 定义和触发事件

Options API示例:

<script>
export default {
  emits: ['update', 'delete'],
  // 或者详细声明
  emits: {
    update: (value) => {
      // 验证事件参数
      return typeof value === 'string';
    },
    delete: null
  },
  methods: {
    handleClick() {
      // 触发事件
      this.$emit('update', 'new value');
    },
    handleDelete() {
      this.$emit('delete');
    }
  }
}
</script>

Composition API示例:

<script setup>
// 简单声明
// const emit = defineEmits(['update', 'delete']);

// 详细声明(推荐)
const emit = defineEmits({
  update: (value) => {
    // 验证事件参数
    return typeof value === 'string';
  },
  delete: null
});

// TypeScript类型声明(更推荐)
// const emit = defineEmits<{
//   update: [value: string];
//   delete: [];
// }>();

const handleClick = () => {
  // 触发事件
  emit('update', 'new value');
};

const handleDelete = () => {
  emit('delete');
};
</script>

8. 插槽

Options API Composition API 说明
$slots useSlots() 访问插槽

Options API示例:

<script>
export default {
  mounted() {
    // 检查插槽是否存在
    if (this.$slots.default) {
      console.log('有默认插槽内容');
    }
    if (this.$slots.header) {
      console.log('有header插槽内容');
    }
  }
}
</script>

<template>
  <div>
    <header v-if="$slots.header">
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
  </div>
</template>

Composition API示例:

<script setup>
import { useSlots, onMounted } from 'vue';

// 获取插槽对象
const slots = useSlots();

onMounted(() => {
  // 检查插槽是否存在
  if (slots.default) {
    console.log('有默认插槽内容');
  }
  if (slots.header) {
    console.log('有header插槽内容');
  }
});
</script>

<template>
  <div>
    <header v-if="slots.header">
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
  </div>
</template>

9. Refs(模板引用)

Options API Composition API 说明
$refs ref() 访问DOM或组件实例

Options API示例:

<script>
export default {
  mounted() {
    // 访问DOM元素
    console.log(this.$refs.input);
    this.$refs.input.focus();
    
    // 访问子组件实例
    console.log(this.$refs.child);
    this.$refs.child.someMethod();
  }
}
</script>

<template>
  <div>
    <input ref="input" />
    <ChildComponent ref="child" />
  </div>
</template>

Composition API示例:

<script setup>
import { ref, onMounted } from 'vue';
import ChildComponent from './ChildComponent.vue';

// 创建ref,变量名必须与模板中的ref属性值相同
const input = ref(null);
const child = ref(null);

onMounted(() => {
  // 访问DOM元素
  console.log(input.value);
  input.value.focus();
  
  // 访问子组件实例
  console.log(child.value);
  child.value.someMethod();
});
</script>

<template>
  <div>
    <input ref="input" />
    <ChildComponent ref="child" />
  </div>
</template>

10. 暴露组件方法

Options API Composition API 说明
自动暴露 defineExpose() 暴露组件内部方法给父组件

Options API示例:

<script>
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++;
    },
    getCount() {
      return this.count;
    }
  }
  // Options API中,所有methods和data都会自动暴露给父组件
}
</script>

Composition API示例:

<script setup>
import { ref } from 'vue';

const count = ref(0);

const increment = () => {
  count.value++;
};

const getCount = () => {
  return count.value;
};

// Composition API中,默认不暴露任何内容
// 需要使用defineExpose显式暴露
defineExpose({
  increment,
  getCount
  // 注意:通常不暴露响应式数据本身
});
</script>

11. Provide / Inject(依赖注入)

Options API Composition API 说明
provide / inject provide() / inject() 跨层级组件通信

Options API示例:

<!-- 祖先组件 -->
<script>
export default {
  data() {
    return {
      theme: 'dark'
    }
  },
  provide() {
    return {
      theme: this.theme,
      // 注意:这样提供的值不是响应式的
      updateTheme: this.updateTheme
    }
  },
  methods: {
    updateTheme(newTheme) {
      this.theme = newTheme;
    }
  }
}
</script>

<!-- 后代组件 -->
<script>
export default {
  inject: ['theme', 'updateTheme'],
  mounted() {
    console.log(this.theme); // 'dark'
    this.updateTheme('light');
  }
}
</script>

Composition API示例:

<!-- 祖先组件 -->
<script setup>
import { ref, provide } from 'vue';

const theme = ref('dark');

const updateTheme = (newTheme) => {
  theme.value = newTheme;
};

// 提供响应式数据
provide('theme', theme);
provide('updateTheme', updateTheme);
</script>

<!-- 后代组件 -->
<script setup>
import { inject, onMounted } from 'vue';

// 注入数据,可以提供默认值
const theme = inject('theme', 'light');
const updateTheme = inject('updateTheme');

onMounted(() => {
  console.log(theme.value); // 'dark'
  updateTheme('light');
});
</script>

12. Mixins(混入)

Options API Composition API 说明
mixins 组合式函数 逻辑复用

Options API示例:

// mixins/logger.js
export const loggerMixin = {
  data() {
    return {
      logCount: 0
    }
  },
  methods: {
    log(message) {
      console.log(message);
      this.logCount++;
    }
  },
  mounted() {
    console.log('Logger mixin mounted');
  }
};

// 使用mixin
export default {
  mixins: [loggerMixin],
  mounted() {
    this.log('组件已挂载');
    console.log(`日志次数:${this.logCount}`);
  }
}

Composition API示例:

// composables/useLogger.js
import { ref, onMounted } from 'vue';

export function useLogger() {
  const logCount = ref(0);
  
  const log = (message) => {
    console.log(message);
    logCount.value++;
  };
  
  onMounted(() => {
    console.log('Logger composable mounted');
  });
  
  return {
    logCount,
    log
  };
}

// 使用组合式函数
import { useLogger } from './composables/useLogger';

const { logCount, log } = useLogger();

onMounted(() => {
  log('组件已挂载');
  console.log(`日志次数:${logCount.value}`);
});

最佳实践

企业级应用场景

场景1:大型组件迁移策略

在企业级项目中,通常不会一次性将所有组件从Options API迁移到Composition API。推荐采用渐进式迁移策略:

<!-- 步骤1:保持Options API,先熟悉Composition API -->
<script>
export default {
  // 保持原有Options API代码
  data() {
    return {
      count: 0,
      message: 'Hello'
    }
  },
  methods: {
    increment() {
      this.count++;
    }
  }
}
</script>

<!-- 步骤2:混合使用,逐步迁移 -->
<script>
import { ref, computed } from 'vue';

export default {
  // 新功能使用Composition API
  setup() {
    const newFeature = ref('');
    const processedFeature = computed(() => newFeature.value.toUpperCase());
    
    return {
      newFeature,
      processedFeature
    };
  },
  // 旧功能保持Options API
  data() {
    return {
      count: 0,
      message: 'Hello'
    }
  },
  methods: {
    increment() {
      this.count++;
    }
  }
}
</script>

<!-- 步骤3:完全迁移到Composition API -->
<script setup>
import { ref, computed } from 'vue';

// 所有逻辑都使用Composition API
const count = ref(0);
const message = ref('Hello');
const newFeature = ref('');

const processedFeature = computed(() => newFeature.value.toUpperCase());

const increment = () => {
  count.value++;
};
</script>

迁移建议

  1. 从小型、独立的组件开始迁移
  2. 优先迁移新功能和新组件
  3. 对于复杂组件,可以先混合使用
  4. 充分测试迁移后的功能
  5. 团队成员需要先学习Composition API基础
场景2:逻辑复用最佳实践

Composition API的最大优势是逻辑复用。下面是一个完整的企业级示例:

// composables/useUserManagement.js
/**
 * 用户管理组合式函数
 * 封装用户相关的所有逻辑,包括获取、更新、删除等操作
 */
import { ref, computed, onMounted } from 'vue';
import { userApi } from '@/api/user';

export function useUserManagement() {
  // 响应式状态
  const users = ref([]);
  const loading = ref(false);
  const error = ref(null);
  const currentPage = ref(1);
  const pageSize = ref(10);
  
  // 计算属性
  const totalPages = computed(() => {
    return Math.ceil(users.value.length / pageSize.value);
  });
  
  const paginatedUsers = computed(() => {
    const start = (currentPage.value - 1) * pageSize.value;
    const end = start + pageSize.value;
    return users.value.slice(start, end);
  });
  
  // 方法
  const fetchUsers = async () => {
    loading.value = true;
    error.value = null;
    try {
      const response = await userApi.getUsers();
      users.value = response.data;
    } catch (err) {
      error.value = err.message;
      console.error('获取用户列表失败:', err);
    } finally {
      loading.value = false;
    }
  };
  
  const deleteUser = async (userId) => {
    try {
      await userApi.deleteUser(userId);
      // 从列表中移除已删除的用户
      users.value = users.value.filter(user => user.id !== userId);
    } catch (err) {
      error.value = err.message;
      throw err;
    }
  };
  
  const updateUser = async (userId, userData) => {
    try {
      const response = await userApi.updateUser(userId, userData);
      // 更新列表中的用户数据
      const index = users.value.findIndex(user => user.id === userId);
      if (index !== -1) {
        users.value[index] = response.data;
      }
    } catch (err) {
      error.value = err.message;
      throw err;
    }
  };
  
  const goToPage = (page) => {
    if (page >= 1 && page <= totalPages.value) {
      currentPage.value = page;
    }
  };
  
  // 生命周期
  onMounted(() => {
    fetchUsers();
  });
  
  // 返回需要暴露的状态和方法
  return {
    // 状态
    users,
    loading,
    error,
    currentPage,
    pageSize,
    // 计算属性
    totalPages,
    paginatedUsers,
    // 方法
    fetchUsers,
    deleteUser,
    updateUser,
    goToPage
  };
}

在组件中使用:

<script setup>
import { useUserManagement } from '@/composables/useUserManagement';
import { ElMessage } from 'element-plus';

// 使用组合式函数,获取所有用户管理相关的功能
const {
  paginatedUsers,
  loading,
  error,
  currentPage,
  totalPages,
  deleteUser,
  goToPage
} = useUserManagement();

// 处理删除操作
const handleDelete = async (userId) => {
  try {
    await deleteUser(userId);
    ElMessage.success('删除成功');
  } catch (err) {
    ElMessage.error('删除失败');
  }
};
</script>

<template>
  <div class="user-management">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading">加载中...</div>
    
    <!-- 错误提示 -->
    <div v-if="error" class="error">{{ error }}</div>
    
    <!-- 用户列表 -->
    <div v-else class="user-list">
      <div v-for="user in paginatedUsers" :key="user.id" class="user-item">
        <span>{{ user.name }}</span>
        <button @click="handleDelete(user.id)">删除</button>
      </div>
    </div>
    
    <!-- 分页 -->
    <div class="pagination">
      <button 
        @click="goToPage(currentPage - 1)" 
        :disabled="currentPage === 1"
      >
        上一页
      </button>
      <span>{{ currentPage }} / {{ totalPages }}</span>
      <button 
        @click="goToPage(currentPage + 1)" 
        :disabled="currentPage === totalPages"
      >
        下一页
      </button>
    </div>
  </div>
</template>

优势分析

  1. 逻辑集中:所有用户管理相关的逻辑都在一个文件中
  2. 易于复用:多个组件可以使用同一个组合式函数
  3. 易于测试:可以单独测试组合式函数
  4. 类型安全:配合TypeScript可以获得完整的类型提示
  5. 按需引入:只引入需要的功能,减少组件代码量

常见陷阱

陷阱1:忘记.value

错误示例:

<script setup>
import { ref } from 'vue';

const count = ref(0);

const increment = () => {
  // ❌ 错误:忘记使用.value
  count++;  // 这不会触发响应式更新
};
</script>

正确做法:

<script setup>
import { ref } from 'vue';

const count = ref(0);

const increment = () => {
  // ✅ 正确:使用.value访问和修改ref的值
  count.value++;
};
</script>

原因分析

  • ref()返回的是一个响应式引用对象,不是原始值
  • 在JavaScript中访问和修改ref需要使用.value
  • 在模板中Vue会自动解包,不需要.value
陷阱2:reactive对象的解构

错误示例:

<script setup>
import { reactive } from 'vue';

const state = reactive({
  count: 0,
  message: 'Hello'
});

// ❌ 错误:直接解构会失去响应性
const { count, message } = state;

const increment = () => {
  count++;  // 这不会触发响应式更新
};
</script>

正确做法:

<script setup>
import { reactive, toRefs } from 'vue';

const state = reactive({
  count: 0,
  message: 'Hello'
});

// ✅ 正确:使用toRefs保持响应性
const { count, message } = toRefs(state);

const increment = () => {
  count.value++;  // 现在可以正常工作
};

// 或者不解构,直接使用state
const increment2 = () => {
  state.count++;  // 这也可以正常工作
};
</script>

原因分析

  • 直接解构reactive对象会失去响应性
  • toRefs()reactive对象的每个属性转换为ref
  • 转换后的ref保持与原对象的响应式连接
陷阱3:watch的immediate选项

错误示例:

<script setup>
import { ref, watch } from 'vue';

const userId = ref(null);
const userData = ref(null);

// ❌ 问题:只有userId变化时才会执行
watch(userId, async (newId) => {
  if (newId) {
    const response = await fetchUser(newId);
    userData.value = response.data;
  }
});

// 如果userId初始值不是null,watch不会立即执行
// 需要手动调用一次fetchUser
</script>

正确做法:

<script setup>
import { ref, watch } from 'vue';

const userId = ref(123);  // 初始值不是null
const userData = ref(null);

// ✅ 正确:使用immediate选项立即执行一次
watch(userId, async (newId) => {
  if (newId) {
    const response = await fetchUser(newId);
    userData.value = response.data;
  }
}, {
  immediate: true  // 组件挂载时立即执行一次
});
</script>

原因分析

  • 默认情况下,watch只在数据变化时执行
  • 使用immediate: true可以在组件挂载时立即执行一次
  • 这对于需要根据初始值加载数据的场景非常有用

性能优化建议

建议1:合理选择ref和reactive
<script setup>
import { ref, reactive } from 'vue';

// ✅ 推荐:基本类型使用ref
const count = ref(0);
const message = ref('Hello');
const isActive = ref(false);

// ✅ 推荐:对象使用reactive(如果不需要整体替换)
const user = reactive({
  name: '张三',
  age: 25,
  email: 'zhangsan@example.com'
});

// ❌ 不推荐:对象使用ref(除非需要整体替换)
const user2 = ref({
  name: '李四',
  age: 30
});
// 访问属性需要user2.value.name,比较繁琐

// ✅ 但如果需要整体替换对象,ref更合适
const config = ref({ theme: 'dark' });
// 可以整体替换
config.value = { theme: 'light', fontSize: 14 };
</script>
建议2:使用computed缓存计算结果
<script setup>
import { ref, computed } from 'vue';

const items = ref([
  { id: 1, name: '商品A', price: 100, quantity: 2 },
  { id: 2, name: '商品B', price: 200, quantity: 1 },
  { id: 3, name: '商品C', price: 150, quantity: 3 }
]);

// ✅ 推荐:使用computed缓存计算结果
const totalPrice = computed(() => {
  console.log('计算总价');  // 只在items变化时执行
  return items.value.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
});

// ❌ 不推荐:使用方法每次都重新计算
const getTotalPrice = () => {
  console.log('计算总价');  // 每次调用都执行
  return items.value.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
};
</script>

<template>
  <div>
    <!-- computed会缓存结果,多次使用不会重复计算 -->
    <p>总价:{{ totalPrice }}</p>
    <p>总价(含税):{{ totalPrice * 1.1 }}</p>
    
    <!-- 方法每次都会重新计算 -->
    <p>总价:{{ getTotalPrice() }}</p>
    <p>总价(含税):{{ getTotalPrice() * 1.1 }}</p>
  </div>
</template>
建议3:避免在模板中使用复杂表达式
<script setup>
import { ref, computed } from 'vue';

const users = ref([
  { id: 1, name: '张三', age: 25, status: 'active' },
  { id: 2, name: '李四', age: 30, status: 'inactive' },
  { id: 3, name: '王五', age: 28, status: 'active' }
]);

// ❌ 不推荐:在模板中使用复杂表达式
// <div v-for="user in users.filter(u => u.status === 'active').sort((a, b) => a.age - b.age)">

// ✅ 推荐:使用computed处理复杂逻辑
const activeUsers = computed(() => {
  return users.value
    .filter(user => user.status === 'active')
    .sort((a, b) => a.age - b.age);
});
</script>

<template>
  <div>
    <!-- 模板更简洁,逻辑更清晰 -->
    <div v-for="user in activeUsers" :key="user.id">
      {{ user.name }} - {{ user.age }}岁
    </div>
  </div>
</template>

实践练习

练习1:Options API转Composition API(难度:简单)

需求描述

将下面的Options API组件转换为Composition API:

<script>
export default {
  data() {
    return {
      firstName: '',
      lastName: ''
    }
  },
  computed: {
    fullName() {
      return `${this.firstName} ${this.lastName}`;
    }
  },
  methods: {
    updateFirstName(value) {
      this.firstName = value;
    },
    updateLastName(value) {
      this.lastName = value;
    }
  },
  mounted() {
    console.log('组件已挂载');
  }
}
</script>

<template>
  <div>
    <input :value="firstName" @input="updateFirstName($event.target.value)" />
    <input :value="lastName" @input="updateLastName($event.target.value)" />
    <p>全名:{{ fullName }}</p>
  </div>
</template>

实现提示

  1. 使用ref()定义响应式数据
  2. 使用computed()定义计算属性
  3. 直接定义函数替代methods
  4. 使用onMounted()替代mounted钩子
  5. 记得在访问ref时使用.value

参考答案

<script setup>
import { ref, computed, onMounted } from 'vue';

// 使用ref定义响应式数据
const firstName = ref('');
const lastName = ref('');

// 使用computed定义计算属性
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});

// 直接定义函数
const updateFirstName = (value) => {
  firstName.value = value;
};

const updateLastName = (value) => {
  lastName.value = value;
};

// 使用onMounted替代mounted钩子
onMounted(() => {
  console.log('组件已挂载');
});
</script>

<template>
  <div>
    <!-- 模板部分保持不变 -->
    <input :value="firstName" @input="updateFirstName($event.target.value)" />
    <input :value="lastName" @input="updateLastName($event.target.value)" />
    <p>全名:{{ fullName }}</p>
  </div>
</template>

答案解析

  1. 响应式数据:使用ref()替代data(),因为firstName和lastName是基本类型
  2. 计算属性computed()的用法与Options API类似,但需要使用.value访问ref
  3. 方法:直接定义函数,不需要methods选项
  4. 生命周期:使用onMounted()替代mounted()钩子
  5. 模板:模板部分不需要修改,Vue会自动解包ref

练习2:创建可复用的组合式函数(难度:中等)

需求描述

创建一个useCounter组合式函数,实现以下功能:

  1. 维护一个计数器状态
  2. 提供增加、减少、重置方法
  3. 提供一个计算属性显示计数器是否为偶数
  4. 支持设置初始值和步长
  5. 在两个不同的组件中使用这个组合式函数

实现提示

  1. 创建一个独立的文件存放组合式函数
  2. 使用ref定义响应式状态
  3. 使用computed定义计算属性
  4. 函数接受配置参数(初始值、步长)
  5. 返回需要暴露的状态和方法

参考答案

// composables/useCounter.js
/**
 * 计数器组合式函数
 * @param {number} initialValue - 初始值,默认为0
 * @param {number} step - 步长,默认为1
 * @returns {Object} 计数器状态和方法
 */
import { ref, computed } from 'vue';

export function useCounter(initialValue = 0, step = 1) {
  // 响应式状态
  const count = ref(initialValue);
  
  // 计算属性:判断是否为偶数
  const isEven = computed(() => {
    return count.value % 2 === 0;
  });
  
  // 方法:增加
  const increment = () => {
    count.value += step;
  };
  
  // 方法:减少
  const decrement = () => {
    count.value -= step;
  };
  
  // 方法:重置
  const reset = () => {
    count.value = initialValue;
  };
  
  // 方法:设置为指定值
  const setValue = (value) => {
    count.value = value;
  };
  
  // 返回需要暴露的内容
  return {
    count,
    isEven,
    increment,
    decrement,
    reset,
    setValue
  };
}

组件A:基础计数器

<script setup>
import { useCounter } from '@/composables/useCounter';

// 使用默认配置
const { count, isEven, increment, decrement, reset } = useCounter();
</script>

<template>
  <div class="counter">
    <h2>基础计数器</h2>
    <p>当前值:{{ count }}</p>
    <p>是否为偶数:{{ isEven ? '是' : '否' }}</p>
    <div class="buttons">
      <button @click="decrement">-1</button>
      <button @click="reset">重置</button>
      <button @click="increment">+1</button>
    </div>
  </div>
</template>

组件B:自定义步长计数器

<script setup>
import { useCounter } from '@/composables/useCounter';

// 使用自定义配置:初始值100,步长10
const { count, isEven, increment, decrement, reset, setValue } = useCounter(100, 10);

// 可以创建多个独立的计数器实例
const counter2 = useCounter(0, 5);
</script>

<template>
  <div class="counter">
    <h2>自定义步长计数器</h2>
    <p>当前值:{{ count }}</p>
    <p>是否为偶数:{{ isEven ? '是' : '否' }}</p>
    <div class="buttons">
      <button @click="decrement">-10</button>
      <button @click="reset">重置到100</button>
      <button @click="increment">+10</button>
      <button @click="setValue(0)">设置为0</button>
    </div>
    
    <hr />
    
    <h2>第二个计数器(步长5)</h2>
    <p>当前值:{{ counter2.count }}</p>
    <div class="buttons">
      <button @click="counter2.decrement">-5</button>
      <button @click="counter2.increment">+5</button>
    </div>
  </div>
</template>

答案解析

  1. 组合式函数设计

    • 接受配置参数,提供灵活性
    • 封装所有相关逻辑,包括状态、计算属性和方法
    • 返回对象,方便按需解构
  2. 逻辑复用

    • 同一个组合式函数可以在多个组件中使用
    • 每次调用都创建独立的实例,互不影响
    • 可以在同一个组件中创建多个实例
  3. 优势体现

    • 代码复用:避免重复编写相同逻辑
    • 逻辑集中:所有计数器相关逻辑都在一个文件中
    • 易于测试:可以单独测试组合式函数
    • 类型安全:配合TypeScript可以获得完整的类型提示
  4. 与Mixin对比

    • 组合式函数的来源清晰(显式导入)
    • 不会有命名冲突问题
    • 可以传递参数配置
    • 更好的TypeScript支持

进阶阅读

大规模监控数据下的 JSON 优化:从 OOM 崩溃到极致吞吐的进阶之路

2026年2月17日 07:57

一、 内存架构优化:破解“内存翻倍”魔咒

在处理大规模监控 JSON 时,最隐形的杀手是 “对象实例化开销”

1. 为什么全量解析会崩掉内存?

当你执行 JSON.parse 处理一个 100MB 的字符串时,内存占用并不是增加 100MB。

  • 字符串拷贝:V8 需要一份原始字符串的内存。
  • 对象图谱(Object Graph) :解析出的每个 Key 和 Value 都是一个独立的 JS 对象,会有额外的指针和隐藏类(Hidden Class)开销。
  • 最终结果:100MB 的原始数据可能在内存中膨胀到 300MB-500MB,直接诱发频繁的 Full GC

2. 流式解析(Streaming Parser)的深度实践

在监控后端分析场景,应引入 状态机解析

  • 技术实现:使用 JSONStream。它不会一次性把整个 JSON 加载进内存,而是像吃拉面一样,一根一根(一个节点一个节点)地处理。
  • 实战案例:在解析上亿条埋点组成的 JSON 数组时,通过流式监听 rows.* 路径,处理完一个对象后立即交给聚合引擎并释放内存,将内存波动控制在恒定范围内。

二、 序列化压榨:绕过 V8 的通用检查

Node.js 原生的 JSON.stringify 为了通用性,在每次调用时都会进行复杂的类型探测和属性遍历。

1. Schema 预编译:快到飞起的秘密

如果你上报的监控埋点格式是固定的(例如:{ event: string, duration: number }),那么预编译序列化是最佳选择。

  • fast-json-stringify:它会预先生成一段高度优化的 JS 函数,直接拼接字符串,跳过所有的逻辑判断。
  • 性能增益:在 Benchmark 测试中,针对固定结构的监控数据,其速度比原生方法快 200% 到 500%

2. 避免属性检索:隐藏类(Hidden Classes)的复用

在生成大型监控报告时,确保你构建的对象具有一致的形状

  • 技巧:始终以相同的顺序给对象属性赋值。这能让 V8 引擎复用隐藏类,极大地提升后续 stringify 时的查找效率。

三、 传输层的“降维打击”:从文本到二进制

你应该意识到 JSON 的文本格式在大规模传输中是极度低效的(冗余的引号、重复的 Key、Base64 编码后的体积膨胀)。

1. 字段映射压缩(Field Mapping)

在监控 SDK 上报阶段,通过字典映射减少 Payload:

  • 原始数据{"errorMessage": "timeout", "errorCode": 504}
  • 压缩后{"m": "timeout", "c": 504}
  • 效果:仅此一项,在每秒万级请求下,就能为数据网关节省 TB 级的月带宽流量。

2. 跨越 JSON:Protobuf 与 MessagePack

当 JSON 的解析 CPU 占用率超过 30% 时,必须考虑协议升级:

  • Protobuf(Protocol Buffers) :通过预定义的 ID 映射字段名,不传输任何 Key 文本。解析速度极快,因为它几乎就是内存数据的直接二进制映射。
  • MessagePack:如果你需要保留动态性(不需要提前定义 Schema),MessagePack 提供了比 JSON 更小的体积和更快的编解码速度,非常适合在 BFF 内部服务之间传递监控中间件。

你真的懂 JSON 吗?那些被忽略的底层边界与性能陷阱

2026年2月17日 07:57

一、 语法边界:JSON 并不是 JavaScript 的子集

这是一个常见的误区。虽然 JSON 源于 JS,但它的规范(RFC 8259)比 JS 严格且局限得多。

1. 那些被“吞掉”的类型

在执行 JSON.stringify(obj) 时,JS 引擎会进行一套复杂的类型转换,而这些转换往往是非对称的:

  • undefined、函数、Symbol

    • 作为对象属性时:会被直接忽略(Key 都会消失)。
    • 作为数组元素时:会被转化为 null
    • 独立值时:返回 undefined
  • 不可枚举属性:默认会被完全忽略。

  • BigInt:会直接抛出 TypeError,因为 JSON 规范中没有对应的大数表示协议。

2. 数值的精度丢失

JSON 的数值遵循 IEEE 754 双精度浮点数。如果你在处理前端监控中的高精纳秒级时间戳,直接序列化可能会导致精度被截断。


二、 序列化的高级操纵:Replacer 与 toJSON 的深度应用

当你需要处理复杂的业务对象(比如含有循环引用或敏感数据)时,基础的 JSON.stringify 就不够用了。

1. toJSON:对象的自白

如果一个对象拥有 toJSON 方法,序列化时会优先调用它。这在处理复杂类实例(Class)时非常有用:

JavaScript

class User {
  constructor(name, pwd) { this.name = name; this.pwd = pwd; }
  toJSON() { return { name: this.name }; } // 自动屏蔽敏感字段
}

2. Replacer 过滤器:解决循环引用

面对嵌套极深的监控数据,循环引用会导致进程崩溃。我们可以利用 Replacer 的第二个参数(函数或数组)来进行“外科手术”:

JavaScript

const seen = new WeakSet();
const safeJson = JSON.stringify(data, (key, value) => {
  if (typeof value === "object" && value !== null) {
    if (seen.has(value)) return "[Circular]"; // 标记循环引用而非报错
    seen.add(value);
  }
  return value;
});

三、 性能深水区:V8 引擎是如何“吃”掉 JSON 的?

在 Node.js 服务端,大规模的 JSON 处理往往是 CPU 的头号杀手。

1. 为什么 JSON.parse 比 JS 字面量快?

这是一个反直觉的结论:解析一段字符串 JSON.parse('{"a":1}') 通常比 JS 引擎解析代码 {a:1} 快。

  • 原因:JS 解析器需要进行复杂的词法和语法分析(考虑到变量提升、作用域等),而 JSON 解析器是单向、无状态的。
  • 优化建议:对于大型静态配置,直接以字符串形式存放并用 JSON.parse 载入,能有效缩短代码冷启动的解析时间(Parse Time)。

2. 阻塞与内存的“双重打击”

  • 同步阻塞JSON.stringify 在处理 10MB 以上的对象时,会阻塞 Event Loop 几十毫秒。在高并发环境下,这足以导致后续请求全部超时。
  • 内存膨胀:序列化时,V8 会先生成一个完整的巨大字符串放入堆内存中。如果你的对象接近 1GB,序列化过程可能会瞬间触发 OOM (Out of Memory)

3. 安全陷阱:JSON 劫持与注入

  • __proto__ 注入:不安全的 JSON.parse(特别是在某些旧库中)可能被恶意构造的字符串攻击,通过原型链污染篡改全局逻辑。
昨天 — 2026年2月16日掘金 前端

WebMCP 时代:在浏览器中释放 AI 的工作能力

作者 CharlesYu01
2026年2月16日 17:49

随着 AI Agent 的广泛应用,传统的 Web 自动化与 Web 交互模式正在迎来根本性变化。WebMCP 是一个未来派的技术提案,它不仅改变了 AI 访问 Web 的方式,还为 AI 与前端应用之间建立起了 协议级的交互通道。本文从WebMCP架构分层解析这项技术及其工程意义。

面对 GEO 与 Agent 应用逐步弱化浏览器入口价值的趋势,浏览器厂商必须主动跟进,通过技术升级与生态重构来守住自身核心阵地。


一、WebMCP 是什么?

WebMCP(Web Model Context Protocol)是一种 客户端 JavaScript 接口规范,允许 Web 应用以结构化、可调用的形式向 AI Agent 暴露其功能(tools)。WebMCP 的核心目标是:

让 Web 应用拥有一组可被 AI Agents 调用的工具函数,避免 AI 通过截图 + DOM 模拟点击这样的低效方式去理解和操作页面。

WebMCP 允许开发者将 Web 应用的功能“以工具形式”公开,供 Agents、浏览器辅助技术等访问。页面将现有的 JavaScript 逻辑包装成与自然语言输入对应的“tools”,AI Agents 可以直接调用它们,而不是模拟用户行为。

换句话说:

WebMCP 是前端版的 MCP 工具协议:它让 Web 应用自己变成一个能被 AI 调用的、语义明确的接口服务器。


二、核心理念:让 Web App 成为 AI 可调用的工具集

WebMCP 的核心机制由三部分构成:

1. 工具注册与调用

页面通过 navigator.modelContext.registerTool() 或类似 API 把自己内部的 JS 功能(如搜索、筛选、提交、获取数据)注册为可调用的工具(tools)。这些 tools 带有:

  • 名称
  • 自然语言描述
  • 输入/输出结构定义(JSON schema)

Agents 识别到这些 tools 后,就可以直接调用,而不需要重新解析 DOM。


2. 语义描述与结构化调用

WebMCP 的工具接口是结构化的,而不是 UI 操作序列:

filterTemplates(description: string) → UI 更新
getDresses(size, color) → 返回商品列表
orderPrints(copies, page_size, page_finish) → 下单

这比视觉模拟更可靠、更高效。


3. 人机协作而非全自动

WebMCP 不是为了让 AI 完全替代用户,而是为了让用户和 AI 协同完成任务。它强调:

  • 共享上下文
  • AI 与用户同时可见的执行状态
  • 用户有权审查/接受 AI 的动作

不像纯后台机器人,WebMCP 是“在 UI 里协作”的模型。


三、基于 Browser + WebMCP 的 Agent 驱动架构

image.png

这张图展示了 WebMCP 在浏览器场景下的设计思路:


1)AI Platform(大模型)

  • 负责理解用户意图
  • 识别需要调用的 WebMCP 工具
  • 并发送工具调用指令

2)Browser-integrated Agent(浏览器 Agent)

这个组件负责:

  • 将 LLM 指令转为工具调用
  • 与 WebMCP JS 进行交互
  • 在当前网页上下文执行注册的 JavaScript 工具代码

它类似一个“中间控制层”,连接了一端的 AI 推理和另一端的前端工具。


3)WebMCP JS

运行在页面内部的代理代码:

  • 负责注册和执行 tools
  • 与 Agent 进行通信
  • 在正常 Web 环境中执行定义好的工具函数

这意味着:

页面本身是一个 MCP Server ,但运行在客户端。


4)Third-Party HTTP 服务

仍然是页面自身依赖的服务端业务逻辑:

  • 通常的业务 API
  • 页面使用这些 API 完成任务
  • 也可以在工具内部直接调用

核心意义总结

这张图的核心思想是:

在浏览器里增强 Web 应用,让 AI Agent 能调用前端定义好的交互能力,而不是模拟用户行为。

它是一个 “前端即服务的 MCP Server” 模式。


四、为什么这是一种范式级的变革?

1)结构良好的能力暴露

传统 Agents 访问网站靠:截图 +Vision 识别 + DOM 模拟

这是低效、易错且不稳定的。

WebMCP 直接告诉 AI:

你的工具是 filterTemplates(criteria)
不要再猜测页面结构

这意味着 AI 不再“模拟人”,而是“直接调用真实功能”。

2)前端逻辑复用

WebMCP 允许:

  • 复用现有前端逻辑
  • 业务功能无需写额外后端 API
  • 使用现有组件构建工具

3)提升安全和用户控制

WebMCP 需要用户授权,且工具执行会明显提示用户,这符合“人机协作”设计,还能避免:

  • 未授权数据泄露
  • 无感知的全自动操作

这比无 UI 后端自动化更可控。

五、典型使用场景

使用WebMCP订奶茶

你说:
帮我找一家评分高、离我近一点的奶茶店,最好 20 元以内。

当前页面注册了一个 WebMCP 工具:

/**
 * 根据自然语言描述搜索奶茶店
 *
 * description - 用户对店铺的需求描述(自然语言)
 * max_price - 人均价格上限(单位:人民币)
 */
searchMilkTeaShops(description, max_price)

浏览器Agent判断这个工具最符合用户意图,于是调用:

searchMilkTeaShops(
  "评分高,距离近,出餐快",
  20
)

页面内部会把自然语言转为已有筛选条件,例如:

  • 评分 ≥ 4.5
  • 距离 ≤ 2km
  • 人均 ≤ 20 元

然后刷新页面,只展示符合条件的店铺。

浏览器Agent回复:
我帮你筛选了几家评分高、距离近、价格合适的奶茶店,要不要限定品牌?

你说:
优先考虑喜茶或者蜜雪冰城,少糖。

页面还注册了一个工具:

/**
 * 在当前结果中按品牌和口味偏好筛选
 *
 * brands - 品牌数组,例如 ["喜茶", "蜜雪冰城"]
 * sweetness - 甜度偏好,例如 ["正常糖", "少糖", "无糖"]
 */
refineShops(brands, sweetness)

浏览器Agent调用:

refineShops(
  ["喜茶", "蜜雪冰城"],
  ["少糖"]
)

页面更新,只展示符合条件的店铺和推荐饮品。

你点进一家店铺页面。页面加载后注册了新的工具:

/**
 * 根据口味偏好推荐饮品
 *
 * description - 对饮品口味的自然语言描述
 * max_price - 单杯价格上限
 */
recommendDrinks(description, max_price)

你说:
给我推荐一杯清爽一点的水果茶,不要太甜,20 元以内。

调用:

recommendDrinks(
  "清爽水果茶,少糖,不腻",
  20
)

页面自动高亮并展示 2–3 款符合条件的饮品。

你选中其中一杯。

页面注册了下单相关工具:

/**
 * 将指定饮品加入购物车
 *
 * product_id - 饮品 ID
 * options - 规格选项,例如甜度、冰量
 */
addDrinkToCart(product_id, options)

/**
 * 提交订单
 */
checkout()

浏览器Agent调用:

addDrinkToCart(
  5567890,
  {
    sweetness: "少糖",
    ice: "少冰"
  }
)

页面提示“已加入购物车”。

浏览器Agent在界面上显示一个提示按钮:
<去结算>

你点击。

浏览器Agent调用:

checkout()

页面跳转到确认订单页,你确认地址并完成支付。

整个过程中,浏览器Agent并没有去“点击筛选按钮”或“模拟输入搜索框”,而是直接调用页面注册的结构化工具函数。页面把原有的搜索、筛选、推荐、加购、下单逻辑封装成 WebMCP 工具,让 AI 可以用更稳定、更语义化的方式操作。

这就是 WebMCP 的核心理念:
不是让 AI 像人一样操作页面,而是让页面主动把能力暴露出来,供 AI 调用。

六、demo

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>WebMCP Article Demo</title>
  <style>
    body { max-width: 800px; margin: auto; font-family: sans-serif; }
    article { line-height: 1.6; }
  </style>
</head>
<body>

  <h1>WebMCP Article Demo</h1>

  <article id="main-article">
    <h2>示例文章标题</h2>
    <p>这是第一段正文内容。</p>
    <p>这是第二段正文内容。</p>
    <p>这是第三段正文内容。</p>
  </article>

  <script>
    function extractArticleText() {
      // 优先找 article 标签
      let article = document.querySelector("article");

      // 如果没有 article,就尝试主内容容器
      if (!article) {
        article = document.querySelector("main") ||
                  document.querySelector("#content") ||
                  document.body;
      }

      // 清除 script/style
      const clone = article.cloneNode(true);
      clone.querySelectorAll("script, style, nav, footer").forEach(el => el.remove());

      const text = clone.innerText.trim();

      return {
        title: document.title,
        url: location.href,
        content: text,
        length: text.length
      };
    }

    if (navigator.modelContext?.registerTool) {
      console.log("[WebMCP] registering getArticleContent");

      navigator.modelContext.registerTool({
        name: "getArticleContent",
        description: "获取当前页面的文章正文内容",
        inputSchema: {
          type: "object",
          properties: {}
        },
        async execute() {
          const data = extractArticleText();

          return {
            content: [
              {
                type: "text",
                text: JSON.stringify(data, null, 2)
              }
            ]
          };
        }
      });

      console.log("[WebMCP] tool registered");
    } else {
      console.warn("WebMCP not supported in this browser.");
    }
  </script>

</body>
</html>

2026 技术风向:为什么在 AI 时代,PostgreSQL 彻底成为了全栈工程师的首选数据库

作者 NEXT06
2026年2月16日 15:12

在 Web 开发的黄金十年里,LAMP 架构(Linux, Apache, MySQL, PHP)奠定了 MySQL 不可撼动的霸主地位。那是互联网的草莽时代,业务逻辑相对简单,读多写少,开发者对数据库的诉求仅仅是“稳定存储”。

然而,时间来到 2026 年。随着 Node.js 与 TypeScript 生态的统治级渗透,以 Next.js、NestJS 为代表的现代全栈框架(Modern Stack)彻底改变了应用开发的范式。在这个由 Serverless、Edge Computing 和 AI 驱动的新时代,MySQL 逐渐显得力不从心。与此同时,PostgreSQL(下文简称 PG)凭借其惊人的演进速度,成为了全栈工程师事实上的“默认选项”。

这不仅仅是技术偏好的转移,更是架构复杂性倒逼下的必然选择。

建筑学的视角:预制板房 vs 模块化摩天大楼

要理解为什么 PG 在现代架构中胜出,我们必须从底层设计哲学说起。如果把数据库比作建筑:

MySQL 像是一栋“预制板搭建的经济适用房”。
它结构紧凑,开箱即用,对于标准的居住需求(基础 CRUD、简单事务)来说,它表现优异且成本低廉。但是,它的结构是固化的。如果你想在顶楼加建一个停机坪(向量搜索),或者把承重墙打通做成开放式空间(非结构化数据存储),你会发现极其困难。它的存储引擎(InnoDB)虽然优秀,但与上层逻辑耦合较紧,扩展性受限。

PostgreSQL 像是一座“钢结构模块化摩天大楼”。
它的底座(存储与事务引擎)极其坚固,严格遵循 SQL 标准与 ACID 原则。但它最核心的竞争力在于其可插拔的模块化设计(Extensibility)

  • 你需要处理地理空间数据?插入 PostGIS 模块,它立刻变成专业的 GIS 数据库。
  • 你需要做高频时序分析?插入 TimescaleDB 模块。
  • 你需要 AI 向量搜索?插入 pgvector 模块。

PG 不仅仅是一个数据库,它是一个数据平台内核。这种“无限生长”的能力,完美契合了 2026 年复杂多变的业务需求。

全栈工程师偏爱 PG 的三大理由

在 Next.js/NestJS 的全栈生态中,Prisma 和 Drizzle ORM 的流行进一步抹平了数据库的方言差异,让开发者更能关注数据库的功能特性。以下是 PG 胜出的三个关键维度。

1. JSONB:终结 NoSQL 的伪需求

在电商系统中,我们经常面临一个棘手的问题:商品(SKU)属性的非结构化。

  • 衣服:颜色、尺码、材质。
  • 手机:屏幕分辨率、CPU型号、内存大小。
  • 图书:作者、ISBN、出版社。

在 MySQL 时代,为了处理这些动态字段,开发者通常有两种痛苦的选择:要么设计极其复杂的 EAV(实体-属性-值)模型,要么引入 MongoDB 专门存储商品详情,导致需要维护两个数据库,并在应用层处理数据同步(Distributed Transaction 问题)。

MySQL 虽然支持 JSON 类型,但在索引机制和查询性能上一直存在短板。

PG 的解法是 JSONB(Binary JSON)。
PG 不仅仅是将 JSON 作为文本存储,而是在写入时将其解析为二进制格式。这意味着:

  1. 解析速度极快:读取时无需重新解析。
  2. 强大的索引支持:你可以利用 GIN(Generalized Inverted Index,通用倒排索引)对 JSON 内部的任意字段建立索引。

场景示例:
不需要引入 MongoDB,你可以直接在 PG 中查询:“查找所有红色且内存大于 8GB 的手机”。

SQL

-- 利用 @> 操作符利用 GIN 索引进行极速查询
SELECT * FROM products 
WHERE attributes @> '{"color": "red"}' 
AND (attributes->>'ram')::int > 8;

对于全栈工程师而言,这意味着架构的极度简化:One Database, All Data Types.

2. pgvector:AI 时代的“降维打击”

AI 应用的爆发,特别是 RAG(检索增强生成)技术的普及,催生了向量数据库(Vector Database)的需求。

传统的 AI 架构通常是割裂的:

  • MySQL:存储用户、订单等元数据。
  • Pinecone/Milvus:存储向量数据(Embeddings)。
  • Redis:做缓存。

这种架构对全栈团队简直是噩梦。你需要维护三套基础设施,处理数据一致性,还要编写复杂的胶水代码来聚合查询结果。

PG 的解法是 pgvector 插件。
通过安装这个插件,PG 瞬间具备了存储高维向量和进行相似度搜索(Cosine Similarity, L2 Distance)的能力。更重要的是,它支持 HNSW(Hierarchical Navigable Small World)索引,查询性能足以应对绝大多数生产场景。

实战场景:AI 电商系统的“以图搜图”
用户上传一张图片,系统需要推荐相似商品,但同时必须满足“价格低于 1000 元”且“有库存”的硬性条件。

在 PG 中,这只是一个 SQL 查询

SQL

SELECT id, name, price, attributes
FROM products
WHERE stock > 0                       -- 关系型过滤
  AND price < 1000                    -- 关系型过滤
ORDER BY embedding <=> $1             -- 向量相似度排序($1 为用户上传图片的向量)
LIMIT 10;

这种混合查询(Hybrid Search)能力是 PG 对专用向量数据库的降维打击。它消除了数据搬运的成本,保证了事务的一致性(你肯定不希望搜出来的商品其实已经下架了)。

3. 生态与插件:长期主义的选择

MySQL 的功能迭代主要依赖于 Oracle 官方的发版节奏。而 PG 的插件机制允许社区在不修改核心代码的前提下扩展数据库功能。

在 Node.js 全栈项目中,我们经常会用到:

  • pg_cron:直接在数据库层面运行定时任务,无需在 NestJS 里写 cron job。
  • PostGIS:处理配送范围、地理围栏,这是目前地球上最强大的开源 GIS 引擎。
  • zombodb:将 Elasticsearch 的搜索能力集成到 PG 索引中。

对于全栈工程师来说,PG 就像是一个拥有海量 npm 包的运行时环境,你总能找到解决特定问题的插件。

实战架构图谱:构建 Next-Gen AI 电商

基于上述分析,一个典型的 2026 年现代化全栈电商系统的后端架构可以被压缩得极其精简。我们不再需要“全家桶”式的中间件,一个 PostgreSQL 集群足矣。

架构设计

  • 技术栈:Next.js (App Router) + Prisma ORM + PostgreSQL.
  • 数据模型设计

TypeScript

// Prisma Schema 示例
model Product {
  id          Int      @id @default(autoincrement())
  name        String
  price       Decimal
  stock       Int
  // 核心特性 1: 结构化数据与非结构化数据同表
  attributes  Json     // 存储颜色、尺码等动态属性
  
  // 核心特性 2: 原生向量支持 (通过 Prisma Unsupported 类型)
  embedding   Unsupported("vector(1536)") 
  
  // 核心特性 3: 强一致性关系
  orders      OrderItem[]
  
  @@index([attributes(ops: JsonbPathOps)], type: Gin) // GIN 索引加速 JSON 查询
  @@index([embedding], type: Hnsw) // HNSW 索引加速向量搜索
}

业务流转

  1. 商品录入:结构化字段存入 Column,非结构化规格存入 attributes (JSONB),同时调用 OpenAI API 生成 Embedding 存入 embedding 字段。
  2. 交易环节:利用 PG 成熟的 MVCC(多版本并发控制)和 ACID 事务处理高并发订单写入,无需担心锁竞争(相比 MySQL 的 Gap Lock,PG 在高并发写入下往往表现更优)。
  3. 搜索推荐:利用 pgvector 实现基于语义或图片的推荐,同时结合 attributes 中的 JSON 字段进行精准过滤。

结论:Simplicity is Scalability(简单即是扩展)。少维护一个 MongoDB 和一个 Pinecone,意味着系统故障点减少了 66%,开发效率提升了 100%。

结语:数据库的终局

在 2026 年的今天,我们讨论 PostgreSQL 时,已经不再仅仅是在讨论一个关系型数据库(RDBMS)。

PostgreSQL 已经演变成了一个通用多模态数据平台(General-Purpose Multi-Model Data Platform) 。它既是关系型数据库,也是文档数据库,更是向量数据库和时序数据库。

对于追求效率与掌控力的全栈工程师而言,MySQL 依然是 Web 1.0/2.0 时代的丰碑,但在构建 AI 驱动的复杂应用时,PostgreSQL 提供了更广阔的自由度和更坚实的底层支撑。

拥抱 PostgreSQL,不仅是选择了一个数据库,更是选择了一种“做减法”的架构哲学。

拒绝“盲盒式”编程:规范驱动开发(SDD)如何重塑 AI 交付

作者 NEXT06
2026年2月16日 14:53

前言

在过去的一年里,每一位尝试将 AI 引入生产环境的开发者,大概都经历过从“极度兴奋”到“极度疲惫”的心路历程。

我们惊叹于 LLM(大型语言模型)在几秒钟内生成数百行代码的能力,但随后便陷入了无休止的调试与修正。这种现象被形象地称为“盲盒式编程(Gacha Coding)”:输入一个模糊的提示词,就像投下一枚硬币,得到的结果可能是令人惊喜的 SSR(超级稀有)代码,但更多时候是无法维护的 N 卡(废代码)。

为了修正这些错误,我们被迫化身为“保姆”,在对话框中喋喋不休地纠正 AI 的变量命名、UI 样式和逻辑漏洞。最终我们发现,Debug AI 代码的时间甚至超过了自己手写的时间。

这种困境的根源在于:AI 拥有极强的编码能力(How),但它完全缺乏对业务边界、上下文约束和系统设计的理解(What)。

为了打破这一僵局,软件工程领域正在经历一场从“提示词工程(Prompt Engineering)”向“规范驱动开发(Spec-Driven Development, SDD)”的范式跃迁。

一、核心概念:什么是 SDD?

规范驱动开发(Specification-Driven Development, SDD)并非一个全新的概念,但在 AI 时代,它被赋予了全新的生命力。

在传统的软件开发模式中,代码是唯一的真理(Source of Truth) 。文档(PRD、API 文档)往往只是开发的参考,随着项目的迭代,文档与代码必然发生脱节,最终沦为具文。

而在 SDD 模式下,规范(Specification)成为了唯一的真理

The Product Requirements Document (PRD) isn't a guide for implementation; it's the source that generates implementation.

这是一个根本性的认知反转:

  • 传统模式:想法 

    →→
    

     文档(参考)

    →→
    

     人脑翻译 

    →→
    

     代码(真理)。

  • SDD 模式:想法 

    →→
    

     规范(真理)

    →→
    

     AI 翻译 

    →→
    

     代码(衍生品)。

在这种架构下,AI 不再是一个需要你时刻盯着的“副驾驶(Copilot)”,它晋升为一个高效的“编译器(Compiler)”或“引擎”。它读取自然语言编写的、结构严密的规范,并将其确定性地转化为可执行代码。

二、从“聊天”到“契约”:普通提示词 vs. SDD

许多开发者误以为 SDD 就是写更长的 Prompt,这是一种误解。Prompt Engineering 与 SDD 在本质上存在维度级的差异。

1. 提示词工程(Prompt Engineering)

  • 本质:基于对话的口头指令。
  • 特征:线性、碎片化、易遗忘上下文。
  • 痛点:由于缺乏全局约束,AI 容易产生幻觉。每次对话都是一次独立的“抽卡”,结果高度随机。
  • 维护性:极低。一旦业务逻辑变更,需要重新进行多轮对话,且难以保证不破坏原有功能。

2. 规范驱动开发(SDD)

  • 本质:基于文档的工程合同。
  • 特征:结构化、持久化、可版本控制。
  • 优势:通过预先定义的数据结构、状态机和接口规范,锁定了 AI 的解空间。
  • 维护性:高。修改业务逻辑只需修改规范文档,然后让 AI 重新生成代码。

为什么 SDD 现在才爆发?

在过去 20 年(如 MDA 模型驱动架构时期),我们一直试图用 UML 或 DSL 生成代码,但失败了。因为传统的转换器太僵化,无法处理模糊的自然语言。

现在的 LLM 跨越了一个关键门槛:能够准确理解复杂的逻辑上下文,并将自然语言规范可靠地转化为工作代码。  AI 填补了从“非形式化规范”到“形式化代码”之间缺失的拼图。

三、实战方法论:如何构建“虚拟流水线”

要落地 SDD,不能指望一句通用的指令。我们需要在 Prompt 中构建一个“虚拟团队”,让 AI 分阶段产出规范,最后再执行编码。

这是一个分层约束的过程:

第一步:虚拟产品经理(The PM)——产出 PRD

AI 需要首先明确业务的边界。不要直接让它写代码,而是让它生成一份包含以下内容的 PRD:

  • 用户故事:谁在什么场景下解决什么问题。
  • 异常流程:断网了怎么办?输入负数怎么办?数据为空怎么显示?
  • 数据闭环:数据从哪里来,存到哪里去,如何流转。

第二步:虚拟设计师(The Designer)——产出设计规范

禁止 AI 随意发挥审美。需要通过规范文件(如 JSON 或 Markdown 表格)定义:

  • Design Tokens:色板、间距、字号的原子化定义。
  • 交互状态:Hover、Active、Disabled 状态下的具体表现。
  • 组件规范:复用哪些现有的 UI 库组件,而非手写 CSS。

第三步:虚拟架构师(The Architect)——产出技术方案

这是保证代码可维护性的关键。在编码前,必须强制约定:

  • 目录结构:明确 /utils、/components、/hooks 的职责划分。
  • 技术栈约束:强制使用特定的库(如 Tailwind, MobX, React Query)。
  • 命名规范:文件命名、变量命名的具体规则。

第四步:执行者(The Coder)——执行合同

当且仅当上述三份文档(Spec)确认无误后,我们才向 AI 下达最终指令:

“作为资深工程师,请阅读上述 PRD、设计规范和技术方案,严格按照规范实现该系统。”

此时,AI 生成的代码将不再是随机的“盲盒”,而是严格遵循合同的工业级交付物。

四、角色重塑:从“码农”到“数字立法者”

随着 SDD 的普及,软件工程师的职业内核正在发生剧变。

生成代码的边际成本正在趋近于零。如果一个功能的实现只需要几秒钟,那么“写代码”本身就不再是核心竞争力。核心竞争力转移到了“定义问题”和“制定规则”上。

未来的开发者将进化为“意图工程师(Intent Engineer)”“数字世界的立法者(Legislator)”。

  • 立法(Legislating) :你需要具备极强的结构化思维,能够将模糊的业务需求拆解为严密、无歧义的 Spec 文档(即法律条文)。
  • 执法(Executing) :AI 负责执行这些条文。如果系统运行结果不符合预期,你不需要去修改 AI 生成的代码(执法过程),而是应该去修改 Spec(法律条款),然后重新触发生成。

结语:回归创造的本质

软件工程界长久以来面临的“文档与代码不同步”的千古难题,极有可能在 SDD 范式下被彻底终结。

当规范成为真理,代码回归工具属性,我们终于可以从繁琐的语法细节和“保姆式纠错”中解放出来。这不是让开发者失业,而是对开发工作的高维升级。

请停止在 IDE 里漫无目的地“抽卡”。从今天起,试着写一份高质量的 Markdown 规范,定义好你的系统边界与意图。这才是 AI 时代开发者应有的姿态。

我写了个 code-review 的 Agent Skill, 没想到火了

作者 神三元
2026年2月16日 14:35

前两天随手写了个 Claude Code 的 Skill,专门做 Code Review 的,发了条推之后就没太在意。

结果第二天醒来一看,GitHub Star 刷刷往上涨,评论区也炸了,不少人说"终于有个靠谱的 Code Review 工具了"。

image.png

说实话,有点意外。

倒不是说这个 Skill 有多了不起,而是它戳中了一个很真实的痛点——大部分团队的 Code Review,要么走过场,要么全靠人肉。

先说说为什么要做这个

做这个 Skill 的起因其实很简单。

我自己平时写代码,改完了之后经常想让 Claude Code 帮我 review 一下。直接跟它说"帮我看看代码有没有问题",它确实会给你一些反馈,但说实话,质量参差不齐。

这就跟新来的实习生做 Code Review 一样,不是他不想认真看,是他不知道该看什么、怎么看、按什么优先级来。

所以问题的本质是:模型需要一套结构化的 Review 框架,告诉它该检查什么、怎么分级、用什么格式输出。

这不就是 Skill 最擅长干的事吗?

code-review-expert 是什么

一句话概括:一个让 AI 用资深工程师的视角帮你做 Code Review 的 Skill。

安装方式就一行:

npx skills add sanyuan0704/code-review-expert

装好之后在 Claude Code 里输入 /code-review-expert,它就会自动 review 你当前的 git changes。

整个 review 流程我是精心设计过的,分成这么几步:

第一步:Preflight(了解改动范围)

它会先跑 git diff 看看你改了哪些文件、改了多少行。如果改动量超过 500 行,它会先按模块分批 review,不会一口气全看完然后给你一堆乱七八糟的反馈。

第二步:SOLID + 架构检查

这一步是我花了最多时间打磨的。我写了一份详细的 SOLID checklist,把每个原则对应的"坏味道"都列出来了。

比如检查 SRP(单一职责),它不会只是泛泛地说"这个文件职责太多了",而是会问一个很具体的问题:"这个模块有几个不同的修改理由?" 如果一个文件既管 HTTP 请求,又管数据库操作,还管业务逻辑,那它大概率违反了 SRP。

第三步:发现可以删掉的代码

这步其实挺有意思的。很多项目里都有一堆死代码——feature flag 关掉的、被废弃的 API、没人用的工具函数。它会帮你找出来,并且区分"可以直接删"和"需要制定计划再删"两种情况。

第四步:安全扫描

XSS、SQL 注入、SSRF、路径穿越、竞态条件、密钥泄露……这些它都会检查。

其中竞态条件(Race Condition)这块我写的特别详细,因为这是很多人在 review 时最容易忽略的。它会专门去找 check-then-act 模式、读-改-写操作、并发数据库访问这些容易出问题的场景。

第五步:代码质量扫描

错误处理有没有吞掉异常?有没有数据库的 N+1 查询?空值检查到不到位?这些"小问题"在生产环境里都可能变成大事故或者性能问题。

最后:结构化输出 + 确认

所有发现按严重程度分成四个等级:

等级 含义 怎么处理
P0 严重 必须 block merge
P1 高危 应该在合并前修复
P2 中等 这个 PR 修或者建个 follow-up
P3 低优 可选优化

输出之后,它不会自作主张去改代码。而是先问你:要修全部,还是只修 P0/P1,或者修指定的。

这个"先 review 再确认"的设计是我特意做的——Code Review 的价值不只是发现问题,更重要的是让你理解问题。如果 AI 直接帮你改了,你连有什么问题都不知道,那这个 review 就没意义了。

为什么我觉得它火了

发完推之后,仓库几天内涨到了 460+ Star,40+ Fork。

评论区和私信里,大家反馈最多的是两点:

第一,"终于有个体系化的 Review 方案了"

很多独立开发者和小团队,根本没有 Code Review 的流程。不是不想做,是没人帮你 review。有了这个 Skill,相当于随时有个资深工程师帮你把关。

这个需求其实比我想象的要大。我之前以为 Code Review 主要是大厂的需求,没想到独立开发者和小团队对这块的渴求更强烈——因为他们更没有犯错的资本。

第二,"终于不是 AI 味十足的泛泛建议了"

image.png

这要归功于那几份 checklist。我把 security-checklist、solid-checklist、code-quality-checklist 都放在了 references/ 目录下,每份都是实打实的检查清单,不是那种"注意安全问题"之类的废话。

比如安全检查那份,光竞态条件就列了四个子类:共享状态访问、TOCTOU(检查后使用)、数据库并发、分布式系统。每个子类下面都有具体的代码模式和需要问的问题。

这就是 Skill 的魅力——你把专业知识结构化地喂给模型,它的输出质量会有质的提升。

怎么做到的?聊聊 Skill 的设计思路

这个 Skill 的结构很简单:

code-review-expert/
├── SKILL.md                  # 主文件,定义整个 review 流程
├── agents/
│   └── agent.yaml            # Agent 配置
└── references/
    ├── solid-checklist.md    # SOLID 原则检查清单
    ├── security-checklist.md # 安全检查清单
    ├── code-quality-checklist.md # 代码质量检查清单
    └── removal-plan.md       # 代码清理计划模板

核心设计有几个关键点:

1. references 实现按需加载

这是 Skill 体系最优雅的地方。

四份 checklist 的内容加起来好几千字,如果全塞进 SKILL.md,一上来就会吃掉大量上下文窗口。所以我把它们放在 references/ 里,SKILL.md 里只在需要的步骤写 Load references/xxx.md

模型执行到那个步骤时才会去读对应的文件,用完就可以"忘掉"了。这就是之前文章里讲过的 Progressive Disclosure(渐进式加载),Skills 最精妙的设计之一。

2. Workflow 要设计得有节奏感

我试过把所有检查点平铺在一起,效果很差——模型会东一榔头西一棒子,安全问题和命名规范混在一起说。

最后我按照真实的 Code Review 流程来编排:先看改动范围,再看架构设计,然后看安全,最后看代码质量。每一步之间是递进关系,从宏观到微观。

这个设计借鉴了人来做 Code Review 的习惯——好的 reviewer 不会上来就抠细节,而是先理解整体改动的意图和影响范围。

写在最后

你猜我写这个 skill 花了多久?

3,2,1,揭晓答案。

我只花了 10 分钟。不可思议吧。

怎么做到的?现在 claude 官方有一个叫 skill-creator 的 skill,帮你来写 skill,然后基于它可以很快搭出骨架来。后续,就是基于我的专业经验,引导 agent 帮我把一些关键的原则拆分为各个 checklist 文档,聊个几轮,这个高质量的 skill 就完工了。

回头看这件事,我觉得这也是 Skills 生态最让人兴奋的地方:每个有专业积累的开发者,都可以很快把自己的经验沉淀成一个 Skill,让 AI 帮更多人受益。

你不需要会写 MCP Server,不需要懂协议,不需要搞 OAuth 鉴权。就是一个 Markdown 文件 + 几份参考文档,仅此而已。

仓库在这里,欢迎 Star 和提 PR:

GitHub: sanyuan0704/code-review-expert

安装:npx skills add sanyuan0704/code-review-expert

如果你也在做 Skill 开发,或者有什么好用的 Skill 推荐,评论区欢迎来聊。

JWT 登录:原理剖析与实战应用

作者 冻梨政哥
2026年2月16日 14:33

JWT 登录:原理剖析与实战应用

在前后端分离的 Web 应用架构中,身份认证是核心环节之一。HTTP 协议的无状态特性,决定了我们需要一种可靠的方式来维护用户的登录状态,JWT(JSON Web Token)正是解决这一问题的主流方案。本文结合实际代码案例,从 JWT 登录的核心概念、底层原理到落地实现,全方位解析 JWT 登录机制。

一、JWT 登录的核心概念

1. 为什么需要 JWT?

HTTP 协议是无状态的,服务器无法通过协议本身记住用户的登录状态。传统的 Cookie+Session 方案存在跨域难处理、服务器存储压力大等问题;而 JWT 通过将用户身份信息加密为令牌,由客户端存储,服务器无需持久化保存状态,完美适配前后端分离、分布式系统的认证需求。

2. JWT 的核心定义

JWT 是一种基于 JSON 的轻量级身份认证令牌,本质是将用户的核心身份信息(如 ID、用户名)通过加密算法生成一串字符串,客户端在后续请求中携带该令牌,服务器通过解密令牌即可验证用户身份,无需查询数据库。

二、JWT 登录的底层原理

1. JWT 的结构

JWT 令牌由三部分组成,以.分隔:

  • Header(头部) :声明加密算法(如 HS256)和令牌类型(JWT),示例:{"alg":"HS256","typ":"JWT"}
  • Payload(载荷) :存储用户核心身份信息(如 id、name),支持自定义字段,同时包含令牌过期时间(exp)等元数据;
  • Signature(签名) :将 Header 和 Payload 经 Base64 编码后,通过指定算法 + 服务器密钥(secret)加密生成,用于验证令牌是否被篡改。

2. JWT 登录的核心流程

JWT 登录的完整链路可分为 “颁发令牌” 和 “验证令牌” 两个阶段:

阶段 1:颁发令牌(登录请求)
  1. 前端提交用户名 / 密码到服务器;
  2. 服务器验证用户名密码是否正确;
  3. 验证通过后,服务器使用jwt.sign()方法,将用户身份信息、密钥、过期时间作为参数,生成 JWT 令牌;
  4. 服务器将令牌返回给前端,前端将令牌存储(如 localStorage、Cookie);
阶段 2:验证令牌(后续请求)
  1. 前端在请求头(通常是Authorization)中携带 JWT 令牌(格式:Bearer <token>);
  2. 服务器从请求头中提取令牌,通过jwt.decode()/jwt.verify()方法,结合密钥解析令牌;
  3. 验证令牌的有效性(是否过期、是否被篡改),验证通过则识别用户身份,返回对应数据;验证失败则拒绝请求。

3. 核心算法解析

  • sign 方法:核心是 “加密”,输入参数为「用户身份对象」「密钥」「配置项(如过期时间)」,输出为 JWT 令牌。密钥(secret)是服务器的核心机密,需严格保管,避免泄露
  • verify/decode 方法:核心是 “解密 / 验证”,verify会校验令牌的完整性和过期时间,decode仅解析令牌内容(不验证),服务器通过这两个方法还原用户身份。

三、JWT 登录的实战应用(结合代码解析)

以下基于 React+Zustand+Node.js 的实战代码,拆解 JWT 登录的完整实现。

1. 环境准备

安装 JWT 核心依赖:

pnpm i jsonwebtoken

2. 后端实现:令牌颁发与验证

后端基于 Mock 接口实现 JWT 的签发和验证:

import jwt from 'jsonwebtoken'; 
// 服务器密钥,生产环境需配置为环境变量,避免硬编码
const secret = 'cqy123!!!'; 

export default [
    // 1. 登录接口:颁发JWT令牌
    {
        url:'/api/auth/login',
        method:'post',
        response:(req,res) => {
            // 步骤1:获取并清洗前端提交的用户名/密码
            let { name,password } = req.body;
            name = name.trim();
            password = password.trim();

            // 步骤2:基础校验
            if(name == '' || password == ''){
                return { code:400, message:"用户名或密码不能为空" };
            }
            // 步骤3:验证用户名密码(生产环境需查数据库)
            if(name !== 'admin' || password !== '123456'){
                return { code:401, message:"用户名或密码错误" };
            }

            // 步骤4:签发JWT令牌
            const token = jwt.sign(
                { user: { id: 1, name: 'admin', avatar:"xxx" } }, // Payload:用户身份信息
                secret, // 密钥
                { expiresIn:86400*7 } // 过期时间:7天
            );

            // 步骤5:返回令牌和用户信息给前端
            return { token, user: { id: 1, name: 'admin', avatar:"xxx" } };
        }
    },
    // 2. 验证令牌接口:解析用户身份
    {
        url:'/api/auth/check',
        method:'get',
        response:(req,res) => {
            // 步骤1:从请求头提取令牌(格式:Bearer <token>)
            const token = req.headers['authorization'].split(" ")[1];
            try {
                // 步骤2:解析令牌(verify方法更安全,会验证签名和过期时间)
                const decode = jwt.verify(token, secret); 
                return { code: 200, user: decode.user };
            } catch(err) {
                return { code: 400, message:"invalid token" };
            }
        }
    }
]

3. 前端实现:登录交互与令牌存储

前端基于 React+Zustand 实现登录逻辑,将 JWT 令牌持久化存储。

步骤 1:状态管理(Zustand)—— 存储令牌和用户信息
// useUserStore.ts
import { create } from "zustand";
import { persist } from 'zustand/middleware';
import { doLogin } from '@/api/user';
import type { User, Credentials } from "@/types/index";

interface UserState {
    token: string;
    user: User | null;
    isLogin: boolean;
    login:(credentials: Credentials) => Promise<void>;
}

// 创建状态仓库,结合persist中间件持久化到localStorage
export const useUserStore = create<UserState>()(
    persist((set) => ({
        token:"",
        user: null,
        isLogin:false,
        // 登录方法:调用后端接口获取令牌
        login:async ({ name,password }) => {
            const res = await doLogin({name,password});
            // 存储令牌、用户信息,标记登录状态
            set({
                user: res.user,
                token: res.token,
                isLogin:true
            });
        }
    }),{
        name: 'user-store', // localStorage的key
        // 仅持久化核心字段
        partialize:(state) => ({
            token:state.token,
            user: state.user,
            isLogin: state.isLogin
        })
    })
);

步骤 2:登录页面 —— 交互与令牌获取

// Login.tsx
import React, { useState } from 'react';
import { useUserStore } from '@/store/useUserStore';
import { Button, Input, Label } from '@/components/ui';
import { Loader2 } from 'lucide-react';
import type { Credentials } from '@/types';
import { useNavigate } from 'react-router-dom';

export default function Login() {
  const { login } = useUserStore();
  const [loading,setLoading] = useState<boolean>(false);
  // 表单数据:用户名/密码
  const [formData,setFormData] = useState<Credentials>({ name:"", password:"" });
  const navigate = useNavigate();

  // 表单输入处理
  const handleChange = (e:React.ChangeEvent<HTMLInputElement>) => {
    const {id,value} = e.target;
    setFormData((prev) => ({ ...prev, [id]:value }));
  };

  // 登录提交逻辑
  const handleLogin = async (e:React.FormEvent) => {
    e.preventDefault();
    const name = formData.name.trim();
    const password = formData.password.trim();
    if(!name || !password) return;

    setLoading(true);
    try {
      // 调用登录方法,获取并存储JWT令牌
      await login({name,password});
      // 登录成功跳转到首页,替换路由历史(防止回退到登录页)
      navigate('/',{replace:true});
    } catch(err) {
      console.log(err,"登录失败");
    }finally {
      setLoading(false);
    }
  };

  return (
    <div className='min-h-screen flex flex-col items-center justify-center p-6 bg-white'>
      <div className='w-full max-w-sm space-y-6'>
        <form onSubmit={handleLogin} className='space-y-4'>
          <div className='space-y-2'>
            <Label htmlFor='name'>用户名</Label>
            <Input id='name' placeholder='请输入用户名' value={formData.name} onChange={handleChange}/>
          </div>
          <div className='space-y-2'>
            <Label htmlFor='password'>密码</Label>
            <Input id='password' type='password' placeholder='请输入密码' value={formData.password} onChange={handleChange}/>
          </div>
          <Button type='submit'>
            {loading?(<><Loader2 className='mr-2 h-4 w-4 animate-spin'/>登录中...</>):('立即登录')}
          </Button>
        </form>
      </div>
    </div>
  );
}

4. 前端后续请求:携带令牌认证

登录后,前端在发起需要权限的请求时,需在请求头中携带 JWT 令牌:

// 示例:axios请求拦截器
import axios from 'axios';
import { useUserStore } from '@/store/useUserStore';

const request = axios.create({ baseURL: '/api' });

// 请求拦截器:添加Authorization头
request.interceptors.request.use((config) => {
  const { token } = useUserStore.getState();
  if (token) {
    config.headers['Authorization'] = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器:处理令牌过期
request.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      // 令牌过期,清空状态并跳转到登录页
      useUserStore.getState().set({ token: '', user: null, isLogin: false });
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

export default request;

四、JWT 登录的优缺点与注意事项

1. 优点

  • 无状态:服务器无需存储 Session,降低存储压力,适配分布式部署;
  • 跨域友好:令牌由前端存储,可轻松跨域携带,解决 Cookie 跨域问题;
  • 轻量高效:基于 JSON 格式,解析速度快,无需频繁查询数据库。

2. 缺点

  • 令牌无法主动作废:JWT 一旦签发,在过期前无法主动撤销(需结合黑名单机制解决);
  • Payload 不宜存敏感信息:Header 和 Payload 仅经 Base64 编码(非加密),可被解码,不可存储密码等敏感数据;
  • 令牌体积:Payload 内容越多,令牌越长,增加网络传输开销。

3. 生产环境注意事项

  • 密钥(secret)需通过环境变量配置,禁止硬编码;
  • 令牌过期时间不宜过长,结合刷新令牌(Refresh Token)机制;
  • 采用 HTTPS 协议传输令牌,防止中间人攻击;
  • 关键接口需校验令牌的签名和过期时间(使用jwt.verify()而非jwt.decode())。

五、总结

JWT 登录通过 “客户端存储令牌、服务器解密验证” 的方式,完美解决了 HTTP 无状态带来的身份认证问题,是前后端分离架构的首选方案。其核心是通过sign方法生成令牌、verify方法验证令牌,结合前端状态持久化和请求拦截器,可快速实现完整的登录认证体系。在实际应用中,需兼顾安全性和易用性,合理配置令牌过期时间、保管服务器密钥,才能充分发挥 JWT 的优势。

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

作者 SmalBox
2026年2月16日 14:06

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

CustomSpecular 节点是 Unity URP Shader Graph 中用于实现自定义高光光照效果的核心节点。在计算机图形学中,高光反射是模拟光线在物体表面反射时产生的明亮区域,它对于表现材质的质感和真实感至关重要。与 Unity 内置的标准高光计算不同,CustomSpecular 节点提供了更高程度的自定义能力,允许开发者根据特定的材质属性和光照需求来精确控制高光的表现形式。

在物理渲染流程中,高光计算基于光线与材质表面的交互原理。当光线照射到物体表面时,一部分光线会被反射,形成镜面反射。CustomSpecular 节点通过输入材质的光学特性、表面粗糙度信息以及几何数据,能够模拟这种物理现象,生成符合真实世界光学规律的高光效果。

该节点的设计理念是提供灵活而强大的高光计算工具,使开发者能够突破标准光照模型的限制,实现各种特殊的高光效果。无论是模拟金属表面的强烈反射,还是表现非金属材质的微妙光泽,CustomSpecular 节点都能提供必要的计算支持。

在实际应用中,CustomSpecular 节点特别适合用于实现以下场景:需要精确控制高光颜色和强度的特殊材质、基于物理属性的金属材质渲染、自定义的光照模型开发,以及对性能有特殊要求的移动端高光优化。通过合理配置节点的输入参数,开发者可以创建出从逼真的物理材质到风格化的卡通渲染等各种类型的高光效果。

节点描述

CustomSpecular 节点的核心功能是基于经典的 Blinn-Phong 光照模型或更先进的物理渲染模型来计算高光反射。它通过接收多个输入参数来精确控制高光的各个方面,包括强度、颜色、大小和分布。这种计算方式使得材质在不同光照条件下都能保持视觉一致性,同时提供足够的灵活性来满足各种艺术需求。

从技术实现角度来看,CustomSpecular 节点执行的高光计算通常涉及以下几个关键步骤:首先,它根据输入的表面法线、光线方向和视线方向计算中间向量;然后,基于光泽度参数确定高光的光锥大小和强度分布;最后,结合材质的光学特性生成最终的高光颜色值。这个过程确保了高光效果既符合物理规律,又能满足艺术表现的需求。

在 URP 渲染管线中,CustomSpecular 节点的设计充分考虑了移动平台和性能受限环境的优化需求。它使用高效的计算方法,在保证视觉效果的同时尽可能减少着色器的计算开销。这使得它成为开发高质量、高性能渲染效果的理想选择。

物理基础

CustomSpecular 节点的计算基于光学物理原理,特别是菲涅尔效应和微表面理论。菲涅尔效应描述了光线在不同角度照射表面时的反射率变化,而微表面理论则解释了表面微观几何对光线散射的影响。这些物理原理的整合使得 CustomSpecular 节点能够生成更加真实的高光效果。

在能量守恒方面,CustomSpecular 节点的设计确保了反射光线的能量不会超过入射光线的能量,这是实现物理正确渲染的重要原则。通过合理设置输入参数,开发者可以创建出在各种光照环境下都能保持视觉一致性的材质。

艺术控制

除了物理准确性,CustomSpecular 节点还提供了丰富的艺术控制参数。通过调整光泽度、高光颜色和强度等参数,艺术家可以创造出从超现实到高度风格化的各种视觉效果。这种灵活性与物理基础的结合,使得 CustomSpecular 节点成为实现高质量渲染的强大工具。

端口详解

CustomSpecular 节点的端口系统设计精巧,每个端口都有特定的功能和数据要求。深入了解每个端口的作用和相互关系,对于充分发挥节点的潜力至关重要。

输入端口

Specular 输入端口是定义材质基本光学特性的核心输入。它接受两种类型的数据:用于非金属材质的浮点值和用于金属材质的 Vector3 值。这种设计反映了真实世界中不同材料的光学特性差异。

对于非金属材质(也称为电介质),Specular 输入通常使用范围为 0.0 到 1.0 的浮点值。这个值代表了材质的基础反射率,即垂直于表面观察时的反射强度。常见的非金属材质反射率值包括:

  • 水:约 0.02
  • 塑料:约 0.05
  • 玻璃:约 0.08
  • 钻石:约 0.17

对于金属材质,Specular 输入需要 Vector3 值,分别对应 RGB 三个颜色通道的反射率。这是因为金属的反射通常带有颜色,而非简单的灰度值。金属的反射率值通常较高,一般在 0.5 到 1.0 之间,并且不同颜色的反射率可能有所不同。

Smoothness 输入端口控制材质表面的光滑程度,直接影响高光区域的大小和锐利度。这个参数接受 0.0 到 1.0 范围内的浮点值,其中 0.0 表示完全粗糙的表面(产生大面积模糊的高光),1.0 表示完全光滑的表面(产生小而锐利的高光)。

从物理角度来看,Smoothness 参数实际上代表了表面微观粗糙度的倒数。较高的光滑度意味着表面微观几何更加均匀,导致光线反射更加集中;而较低的光滑度则表示表面有更多的微观不规则,导致光线向各个方向散射。

Normal WS 输入端口要求提供世界空间中的表面法线信息。法线定义了表面的朝向,是高光计算中的关键几何数据。正确提供法线信息对于产生准确的高光效果至关重要。

在 Shader Graph 中,获取世界空间法线的常见方法包括:

  • 使用 Vertex Normal 节点并设置为世界空间
  • 从法线贴图采样并转换到世界空间
  • 通过自定义计算生成特殊效果的法线

Light Direction WS 输入端口指定了光源的方向,同样在世界空间中表示。这个方向应该指向光源,即从表面点指向光源位置的向量。在多重光照环境中,通常需要对每个光源分别计算高光贡献。

获取光源方向的典型方法包括:

  • 使用主光源方向(Main Light Direction)
  • 使用额外光源方向(Additional Light Direction)
  • 通过自定义向量定义特殊光源

View Direction WS 输入端口提供了从表面点到摄像机的方向向量。这个向量与光线方向和法线一起,构成了高光计算的核心几何数据。视口方向的准确性直接影响高光位置的正确性。

在 Shader Graph 中,可以通过 View Direction 节点轻松获取世界空间的视口方向。需要注意的是,这个方向应该归一化以确保计算结果的准确性。

输出端口

Out 输出端口生成最终的高光颜色值,以 Vector3 形式表示。这个输出通常需要与漫反射光照和其他光照组件结合,形成完整的表面着色。

输出的高光颜色具有以下特性:

  • 强度与光源强度和材质反射率成正比
  • 颜色受材质光学特性和光源颜色影响
  • 空间分布依赖于表面几何和光泽度参数

在实际使用中,CustomSpecular 节点的输出通常与漫反射颜色相加,并可能受到环境光遮蔽等其他因素的影响,最终形成完整的像素颜色。

使用示例

基础金属材质设置

创建一个基础的金属材质是理解 CustomSpecular 节点功能的绝佳起点。金属材质的高光特性与非金属有显著不同,主要体现在高光强度和颜色方面。

首先,设置 Specular 输入为 Vector3 类型,值设为 (0.8, 0.8, 0.9),这表示一个略带蓝色的金属反射特性。金属的反射率通常较高,因此选择接近 1.0 的值是合适的。蓝色的色调可以模拟不锈钢或钛合金等金属的真实外观。

Smoothness 参数设置为 0.85,表示表面相当光滑但并非完美镜面。这个值会产生一个相对集中但仍有轻微扩散的高光区域,符合大多数抛光金属的视觉特性。

法线输入可以使用标准的顶点法线,通过 Normal Vector 节点获取并设置为世界空间。对于更加细致的表面效果,可以考虑添加法线贴图来模拟微观表面细节。

光源方向通常来自场景的主光源,可以使用 Main Light Direction 节点获取。视口方向通过 View Direction 节点获得,确保设置为世界空间。

连接所有这些输入后,CustomSpecular 节点将输出一个明亮且带有颜色 tint 的高光效果。这个输出可以直接与漫反射组件结合,或者通过乘法与光源颜色混合,以创建更加动态的光照响应。

非金属塑料材质

非金属材质的高光特性与金属有本质区别,主要体现在反射率较低且高光颜色通常为无色(灰度)。创建塑料材质是演示非金属高光的典型示例。

设置 Specular 输入为浮点值 0.05,这是塑料材质的典型反射率。非金属的反射率通常远低于金属,一般在 0.02 到 0.08 范围内。

Smoothness 参数可以根据塑料类型进行调整。对于光滑的注塑塑料,可以设置为 0.7 到 0.9;对于磨砂塑料,则可以设置为 0.3 到 0.6。这个示例中使用 0.75,模拟常见的光滑塑料表面。

法线、光源方向和视口方向的设置与金属材质类似。关键区别在于 Specular 输入使用标量值而非向量,这表示高光颜色将由光源颜色主导,而不受材质颜色影响。

最终的高光效果应该是明亮但不太强烈的白色高光,符合塑料的物理特性。这种设置可以广泛应用于各种塑料制品、涂层表面和其他非金属材料的渲染。

自定义高光形状

通过修改法线输入,可以实现各种特殊的高光形状和效果。这种技术常用于风格化渲染或特殊视觉效果。

一种常见的方法是使用噪声纹理或程序化噪声来扰动法线方向。将噪声纹理采样与基础法线结合,可以创建不规则的高光图案,模拟表面瑕疵或特殊材质特性。

另一种技术是使用数学函数生成自定义法线模式。例如,使用正弦波函数可以创建条纹状的高光效果,适用于CD表面或全息材质等特殊场景。

还可以通过法线贴图引入复杂的高光细节,而无需增加几何复杂度。高质量的法线贴图可以显著增强表面的视觉丰富性,同时保持较低的性能开销。

动态高光效果

通过动态修改输入参数,可以创建响应环境变化的高光效果。这种技术常用于交互元素或动态环境中的物体。

例如,可以将 Smoothness 参数与时间变量关联,创建高光闪烁或脉动效果。这种效果适用于模拟霓虹灯、魔法效果或用户界面元素。

另一种应用是根据视角变化调整高光强度,实现类似菲涅尔效应的增强效果。当视线与表面法线夹角增大时,增加高光强度可以模拟某些特殊材质的视觉特性。

还可以根据场景深度或距离调整高光参数,实现基于距离的细节层次变化。远距离物体可以使用较低的高光精度以优化性能,而近距离物体则展示详细的高光特性。

注意事项

Specular 输入的专业考量

Specular 输入的正确设置对于实现物理正确的渲染至关重要。不同材质的反射率值基于真实的物理测量数据,使用准确的值可以显著提高渲染的真实感。

对于非金属材质,需要特别注意反射率值的范围。虽然技术上可以使用 0.0 到 1.0 的任何值,但真实世界的非金属材质反射率很少超过 0.08。使用超出这个范围的值可能导致不自然的视觉效果。

金属材质的 Specular 输入应该使用 Vector3 值,并且通常包含颜色信息。这是因为金属的反射率通常随波长变化,不同颜色的光可能被不同程度地反射。例如,铜会有偏红的高光,而金则有偏黄的高光。

在性能方面,使用常量 Specular 值通常比使用纹理采样更高效。但对于需要空间变化的反射率,如生锈金属或脏污表面,使用纹理仍然是必要的。

坐标系一致性

所有世界空间输入(Normal WS、Light Direction WS、View Direction WS)必须确保使用相同的坐标系系统。坐标系不一致会导致计算错误和视觉异常。

世界空间法线应该归一化处理,以确保光照计算的准确性。从法线贴图获取的法线需要从切线空间转换到世界空间,这个过程需要正确的切线空间基础向量。

光源方向应该指向光源,并且通常是归一化的向量。在多点光照情况下,需要对每个光源单独计算方向向量。

视口方向是从表面点指向摄像机位置的向量,同样需要归一化处理。在顶点着色器中计算视口方向时,需要注意插值导致的长度变化问题。

性能优化建议

CustomSpecular 节点的计算复杂度主要取决于输入数据的来源和处理方式。通过优化输入数据的获取方式,可以显著提高着色器的性能。

尽可能使用常量或插值数据,避免在片段着色器中进行复杂计算。例如,如果不需要每像素精确的高光,可以在顶点着色器计算高光然后插值到像素。

对于移动平台,考虑使用简化版的高光计算,或者通过质量设置动态调整高光精度。URP 提供了多种质量级别,可以根据目标平台选择适当的高光计算复杂度。

使用适当的精度限定符可以优化性能。对于不需要高精度的计算,可以使用 half 或 fixed 精度而非 float 精度,特别是在移动平台上。

与其他光照组件的整合

CustomSpecular 节点的高光输出需要与漫反射光照、环境光和其他光照组件正确结合,才能形成完整的表面着色。

通常,高光颜色会与光源颜色相乘,然后加到漫反射颜色上。这种加性混合模拟了光线在表面反射的物理过程。

在能量守恒的渲染模型中,需要确保高光和漫反射的总和不超过入射光线的能量。这通常通过适当调整漫反射和高光的相对强度来实现。

对于基于图像的光照(IBL)环境,高光计算可能还需要与环境反射相结合。URP 提供了专门的环境反射节点,可以与 CustomSpecular 节点结合使用。

常见问题排查

当 CustomSpecular 节点产生意外结果时,通常可以从以下几个方面进行排查:

检查所有世界空间向量是否归一化。未归一化的向量会导致光照计算错误,特别是高光强度和位置的不准确。

验证法线方向是否正确。反向法线会导致高光出现在错误的一侧,破坏视觉效果。

确认光源方向指向光源。错误的光源方向会导致高光完全消失或出现在不合理的位置。

检查 Specular 输入的数据类型是否正确。非金属应该使用浮点数,金属应该使用 Vector3,混淆两者会导致不正确的高光颜色。

验证 Smoothness 值是否在合理范围内。超出 0.0-1.0 范围的值可能导致未定义行为或视觉异常。


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

JavaScript 防抖与节流进阶:从原理到实战

作者 wuhen_n
2026年2月16日 06:12

当用户疯狂点击按钮、疯狂滚动页面、疯狂输入搜索关键词时,应用还能流畅运行吗?防抖(Debounce)和节流(Throttle)是应对高频事件的终极武器。本文将从源码层面深入理解它们的差异,并实现一个支持立即执行、延迟执行、取消功能、记录参数的完整版本。

前言:高频事件带来的挑战

我们先来看一些简单的场景:

window.addEventListener('resize', () => {
    // 窗口大小改变时重新计算布局
    recalcLayout(); // 一秒可能触发几十次!
});

searchInput.addEventListener('input', () => {
    // 用户每输入一个字符就发起搜索请求
    fetchSearchResults(input.value); // 浪费大量请求!
});

window.addEventListener('scroll', () => {
    // 滚动时加载更多数据
    loadMoreData(); // 滚动一下触发几十次!
});

在这些场景中,当事件触发频率远高于我们需要的处理频率,就会出现卡顿、闪屏等现象,这就是防抖和节流要解决的核心问题。

理解防抖与节流的本质差异

核心概念对比

类型 防抖 节流
概念 将多次高频操作合并为一次,仅在最后一次操作后的延迟时间到达时执行 保证在单位时间内只执行一次,稀释执行频率
场景示例 电梯关门:等最后一个人进来后才关门,中间如果有人进来就重新计时 地铁安检:无论多少人排队,每秒钟只能通过一个人
执行次数 只执行最后一次 定期执行,不保证最后一次
频率 N次高频调用 → 1次执行 N次高频调用 → N/间隔时间次执行

适用场景对比

防抖场景

  • 搜索框输入(用户停止输入后才搜索)
  • 窗口大小调整(窗口调整完成后重新计算)
  • 表单验证(用户输完才验证)
  • 自动保存(停止编辑后保存)
  • 按钮防连点(避免重复提交)

节流场景

  • 滚动加载更多(滚动过程中定期检查)
  • 动画帧(控制动画执行频率)
  • 游戏循环(固定帧率)
  • 鼠标移动事件(实时位置但不过度频繁)
  • DOM元素拖拽(平滑移动)

防抖函数实现

基础防抖实现

function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    // 每次调用都清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置新的定时器
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}

支持立即执行的防抖

function debounceEnhanced(fn, delay, immediate = false) {
  let timer = null;
  let lastContext = null;
  let lastArgs = null;
  let lastResult = null;
  let callCount = 0;

  return function (...args) {
    lastContext = this;
    lastArgs = args;

    // 第一次调用且需要立即执行
    if (immediate && !timer) {
      lastResult = fn.apply(lastContext, lastArgs);
      callCount++;
      console.log(`立即执行 (调用 #${callCount})`);
    }

    // 清除之前的定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置延迟执行
    timer = setTimeout(() => {
      // 如果不是立即执行模式,或者已经执行过立即执行
      if (!immediate) {
        lastResult = fn.apply(lastContext, lastArgs);
        callCount++;
        console.log(`延迟执行 (调用 #${callCount})`);
      }

      // 清理
      timer = null;
      lastContext = null;
      lastArgs = null;
    }, delay);

    return lastResult;
  };
}

完整版防抖(支持取消、取消、参数记录)

class DebouncedFunction {
  constructor(fn, delay, options = {}) {
    this.fn = fn;
    this.delay = delay;
    this.immediate = options.immediate || false;
    this.maxWait = options.maxWait || null;

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;

    // 参数历史记录
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 调用次数统计
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const time = Date.now();
    this.stats.invokedCount++;
    this.lastInvokeTime = time;

    // 记录参数历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: time,
        type: this.timer ? 'delayed' : 'immediate'
      });

      // 限制历史记录数量
      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行原函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);

    // 清理
    this.lastArgs = null;
    this.lastContext = null;

    return this.lastResult;
  }

  /**
   * 调用防抖函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;
    this.lastCallTime = now;

    // 立即执行模式处理
    if (this.immediate && !this.timer) {
      this._invoke();
    }

    // 清除现有定时器
    if (this.timer) {
      clearTimeout(this.timer);
    }

    // 最大等待时间处理
    if (this.maxWait && this.lastInvokeTime) {
      const timeSinceLastInvoke = now - this.lastInvokeTime;
      if (timeSinceLastInvoke >= this.maxWait) {
        this._invoke();
        return this.lastResult;
      }
    }

    // 设置新的定时器
    this.timer = setTimeout(() => {
      // 非立即执行模式,或者已经执行过立即执行
      if (!this.immediate) {
        this._invoke();
      }
      this.timer = null;
    }, this.delay);

    return this.lastResult;
  }

  /**
   * 取消当前待执行的防抖
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
      this.stats.canceledCount++;
    }

    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消后续
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有待执行的任务
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取调用历史
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      canceledCount: 0
    };
    this.lastResult = null;
    this.lastCallTime = null;
    this.lastInvokeTime = null;
  }
}

节流函数实现

基础节流实现

function throttleTimer(fn, interval) {
  let timer = null;

  return function (...args) {
    if (!timer) {
      timer = setTimeout(() => {
        fn.apply(this, args);
        timer = null;
      }, interval);
    }
  };
}

完整版节流(支持首尾执行)

class ThrottledFunction {
  constructor(fn, interval, options = {}) {
    this.fn = fn;
    this.interval = interval;
    this.leading = options.leading !== false; // 是否立即执行
    this.trailing = options.trailing !== false; // 是否最后执行

    this.timer = null;
    this.lastArgs = null;
    this.lastContext = null;
    this.lastResult = null;
    this.lastInvokeTime = 0;

    // 参数历史
    this.history = [];
    this.maxHistory = options.maxHistory || 10;

    // 统计信息
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
  }

  /**
   * 执行函数
   */
  _invoke() {
    const now = Date.now();
    this.lastInvokeTime = now;
    this.stats.invokedCount++;

    // 记录历史
    if (this.lastArgs) {
      this.history.push({
        args: [...this.lastArgs],
        timestamp: now,
        type: 'executed'
      });

      if (this.history.length > this.maxHistory) {
        this.history.shift();
      }
    }

    // 执行函数
    this.lastResult = this.fn.apply(this.lastContext, this.lastArgs);
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 调用节流函数
   */
  call(...args) {
    const now = Date.now();
    this.stats.callCount++;
    this.lastArgs = args;
    this.lastContext = this;

    // 检查是否在节流期内
    const timeSinceLastInvoke = now - this.lastInvokeTime;
    const isThrottled = timeSinceLastInvoke < this.interval;

    if (isThrottled) {
      this.stats.throttledCount++;

      // 如果需要尾部执行
      if (this.trailing) {
        // 清除现有的尾部执行定时器
        if (this.timer) {
          clearTimeout(this.timer);
        }

        // 设置尾部执行定时器
        const remainingTime = this.interval - timeSinceLastInvoke;
        this.timer = setTimeout(() => {
          if (this.lastArgs) {
            this._invoke();
          }
          this.timer = null;
        }, remainingTime);
      }

      return this.lastResult;
    }

    // 不在节流期内
    if (this.leading) {
      // 头部执行
      this._invoke();
    } else if (this.trailing) {
      // 延迟执行
      if (this.timer) {
        clearTimeout(this.timer);
      }
      this.timer = setTimeout(() => {
        if (this.lastArgs) {
          this._invoke();
        }
        this.timer = null;
      }, this.interval);
    }

    return this.lastResult;
  }

  /**
   * 取消尾部执行
   */
  cancel() {
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
    this.lastArgs = null;
    this.lastContext = null;
  }

  /**
   * 立即执行并取消尾部执行
   */
  flush() {
    if (this.lastArgs) {
      this._invoke();
      this.cancel();
    }
    return this.lastResult;
  }

  /**
   * 判断是否有尾部待执行
   */
  pending() {
    return this.timer !== null;
  }

  /**
   * 获取历史记录
   */
  getHistory() {
    return [...this.history];
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return { ...this.stats };
  }

  /**
   * 重置状态
   */
  reset() {
    this.cancel();
    this.history = [];
    this.stats = {
      callCount: 0,
      invokedCount: 0,
      throttledCount: 0
    };
    this.lastInvokeTime = 0;
    this.lastResult = null;
  }
}

进阶实现与组合优化

支持最大等待时间的防抖

支持最大等待时间的防抖,就是确保函数至少每隔 maxWait 时间执行一次:

function debounceMaxWait(fn, delay, maxWait) {
  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let lastInvokeTime = null;
  let maxTimer = null;

  const invoke = () => {
    lastInvokeTime = Date.now();
    fn.apply(lastContext, lastArgs);
    lastArgs = null;
    lastContext = null;
  };

  const startMaxWaitTimer = () => {
    if (maxTimer) clearTimeout(maxTimer);

    maxTimer = setTimeout(() => {
      if (lastArgs) {
        console.log('达到最大等待时间,强制执行');
        invoke();
      }
    }, maxWait);
  };

  return function (...args) {
    lastArgs = args;
    lastContext = this;

    // 清除现有延迟定时器
    if (timer) {
      clearTimeout(timer);
    }

    // 设置最大等待时间定时器
    if (maxWait && !lastInvokeTime) {
      startMaxWaitTimer();
    }

    // 设置新的延迟定时器
    timer = setTimeout(() => {
      invoke();
      timer = null;

      if (maxTimer) {
        clearTimeout(maxTimer);
        maxTimer = null;
      }
    }, delay);
  };
}

动态调整延迟时间的防抖

根据调用频率动态调整等待时间:

function debounceAdaptive(fn, baseDelay, options = {}) {
  const {
    minDelay = 100,
    maxDelay = 1000,
    factor = 0.8
  } = options;

  let timer = null;
  let lastArgs = null;
  let lastContext = null;
  let callTimes = [];
  let currentDelay = baseDelay;

  const calculateDelay = () => {
    // 计算最近1秒内的调用频率
    const now = Date.now();
    callTimes = callTimes.filter(t => now - t < 1000);
    const frequency = callTimes.length;

    // 根据频率调整延迟
    if (frequency > 10) {
      // 高频调用,增加延迟
      currentDelay = Math.min(currentDelay * (1 + frequency / 100), maxDelay);
    } else if (frequency < 2) {
      // 低频调用,减少延迟
      currentDelay = Math.max(currentDelay * factor, minDelay);
    }

    return currentDelay;
  };

  return function (...args) {
    callTimes.push(Date.now());
    lastArgs = args;
    lastContext = this;

    if (timer) {
      clearTimeout(timer);
    }

    const delay = calculateDelay();
    console.log(`  当前延迟: ${Math.round(delay)}ms (调用频率: ${callTimes.length}/秒)`);

    timer = setTimeout(() => {
      fn.apply(lastContext, lastArgs);
      timer = null;
    }, delay);
  };
}

实际应用场景实战

搜索框自动补全

class SearchAutoComplete {
  constructor(options = {}) {
    this.searchAPI = options.searchAPI || this.mockSearchAPI;
    this.minLength = options.minLength || 2;
    this.debounceDelay = options.debounceDelay || 300;
    this.maxResults = options.maxResults || 10;
    this.cacheResults = options.cacheResults !== false;

    // 搜索缓存
    this.cache = new Map();

    // 创建防抖搜索函数
    this.debouncedSearch = this.createDebouncedSearch();

    // 请求计数器
    this.requestCount = 0;
    this.cacheHitCount = 0;
  }

  /**
   * 模拟搜索API
   */
  async mockSearchAPI(query) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 200));

    // 模拟搜索结果
    const results = [];
    const prefixes = ['apple', 'banana', 'orange', 'grape', 'watermelon'];

    for (let i = 1; i <= 5; i++) {
      results.push({
        id: i,
        text: `${query} 结果 ${i}`,
        category: prefixes[i % prefixes.length]
      });
    }

    return results;
  }

  /**
   * 创建防抖搜索函数
   */
  createDebouncedSearch() {
    const searchFn = async (query) => {
      // 检查缓存
      if (this.cacheResults && this.cache.has(query)) {
        this.cacheHitCount++;
        return this.cache.get(query);
      }

      // 执行真实搜索
      this.requestCount++;
      console.log(`  🌐 [请求#${this.requestCount}] "${query}"`);

      try {
        const results = await this.searchAPI(query);

        // 存入缓存
        if (this.cacheResults) {
          this.cache.set(query, results);

          // 限制缓存大小
          if (this.cache.size > 50) {
            const oldestKey = this.cache.keys().next().value;
            this.cache.delete(oldestKey);
          }
        }

        return results;
      } catch (error) {
        console.error(`搜索失败: ${query}`, error);
        return [];
      }
    };

    // 使用完整版防抖
    return debounceComplete(searchFn, this.debounceDelay, {
      immediate: false,
      maxWait: 1000
    });
  }

  /**
   * 用户输入处理
   */
  onInput(query) {

    // 忽略空查询
    if (!query || query.length < this.minLength) {
      console.log('  查询太短,忽略');
      return Promise.resolve([]);
    }

    // 执行防抖搜索
    return this.debouncedSearch(query)
      .then(results => {
        const limited = results.slice(0, this.maxResults);
        console.log(`返回 ${limited.length} 条结果`);
        this.renderResults(limited);
        return limited;
      })
      .catch(error => {
        console.error('搜索失败:', error);
        return [];
      });
  }

  /**
   * 渲染搜索结果
   */
  renderResults(results) {
    // 实际项目中这里会更新DOM
    console.log(' 搜索结果:');
    results.slice(0, 3).forEach((result, i) => {
      console.log(`    ${i + 1}. ${result.text}`);
    });
    if (results.length > 3) {
      console.log(`... 等 ${results.length} 条`);
    }
  }

  /**
   * 清空缓存
   */
  clearCache() {
    this.cache.clear();
    this.cacheHitCount = 0;
    console.log('搜索缓存已清空');
  }

  /**
   * 获取统计信息
   */
  getStats() {
    return {
      requestCount: this.requestCount,
      cacheHitCount: this.cacheHitCount,
      cacheSize: this.cache.size,
      pending: this.debouncedSearch.pending(),
      debounceStats: this.debouncedSearch.getStats?.()
    };
  }
}

无限滚动加载

console.log('\n=== 无限滚动加载 ===\n');

class InfiniteScroll {
  constructor(options = {}) {
    this.loadMoreAPI = options.loadMoreAPI || this.mockLoadMoreAPI;
    this.throttleInterval = options.throttleInterval || 200;
    this.threshold = options.threshold || 200;
    this.pageSize = options.pageSize || 20;

    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];

    // 创建节流滚动处理函数
    this.throttledScroll = this.createThrottledScroll();

    // 记录最后一次滚动位置
    this.lastScrollPosition = 0;
    this.scrollHistory = [];
  }

  /**
   * 模拟加载更多数据
   */
  async mockLoadMoreAPI(page, pageSize) {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 300));

    // 模拟数据
    const start = page * pageSize;
    const items = [];

    for (let i = 0; i < pageSize; i++) {
      items.push({
        id: start + i,
        title: `项目 ${start + i}`,
        content: `这是第 ${start + i} 个项目的内容`,
        timestamp: Date.now()
      });
    }

    // 模拟没有更多数据
    const hasMore = page < 10;

    return { items, hasMore };
  }

  /**
   * 创建节流滚动处理函数
   */
  createThrottledScroll() {
    const scrollHandler = async (scrollTop, clientHeight, scrollHeight) => {
      const distanceFromBottom = scrollHeight - scrollTop - clientHeight;

      console.log(`滚动位置: ${scrollTop}, 距离底部: ${distanceFromBottom}px`);

      // 记录滚动位置
      this.lastScrollPosition = scrollTop;
      this.scrollHistory.push({
        position: scrollTop,
        timestamp: Date.now()
      });

      // 限制历史记录大小
      if (this.scrollHistory.length > 20) {
        this.scrollHistory.shift();
      }

      // 检查是否需要加载更多
      if (distanceFromBottom < this.threshold) {
        await this.loadMore();
      }
    };

    return throttleComplete(scrollHandler, this.throttleInterval, {
      leading: true,
      trailing: true
    });
  }

  /**
   * 处理滚动事件
   */
  onScroll(event) {
    const target = event.target;
    const scrollTop = target.scrollTop || target.scrollingElement?.scrollTop || 0;
    const clientHeight = target.clientHeight || window.innerHeight;
    const scrollHeight = target.scrollHeight || document.documentElement.scrollHeight;

    this.throttledScroll(scrollTop, clientHeight, scrollHeight);
  }

  /**
   * 加载更多数据
   */
  async loadMore() {
    if (this.isLoading || !this.hasMore) {
      console.log(`${this.isLoading ? '正在加载中' : '没有更多数据'}`);
      return;
    }

    this.isLoading = true;
    this.currentPage++;

    console.log(`加载第 ${this.currentPage} 页数据...`);

    try {
      const result = await this.loadMoreAPI(this.currentPage, this.pageSize);

      this.hasMore = result.hasMore;
      this.items.push(...result.items);

      console.log(`加载完成,当前总条目: ${this.items.length}`);
      console.log(`还有更多: ${this.hasMore}`);

      this.renderItems(result.items);
    } catch (error) {
      console.error('加载失败:', error);
      this.currentPage--; // 回退页数
    } finally {
      this.isLoading = false;
    }
  }

  /**
   * 渲染新加载的项目
   */
  renderItems(newItems) {
    // 实际项目中这里会更新DOM
    console.log('新增项目:');
    newItems.slice(0, 3).forEach((item, i) => {
      console.log(`${item.id}. ${item.title}`);
    });
    if (newItems.length > 3) {
      console.log(`... 等 ${newItems.length} 条`);
    }
  }

  /**
   * 重置到顶部
   */
  reset() {
    this.currentPage = 0;
    this.hasMore = true;
    this.isLoading = false;
    this.items = [];
    this.scrollHistory = [];
    console.log('滚动列表已重置');
  }

  /**
   * 获取滚动统计
   */
  getScrollStats() {
    if (this.scrollHistory.length < 2) {
      return { avgSpeed: 0 };
    }

    const recent = this.scrollHistory.slice(-10);
    let totalSpeed = 0;

    for (let i = 1; i < recent.length; i++) {
      const distance = recent[i].position - recent[i - 1].position;
      const timeDiff = recent[i].timestamp - recent[i - 1].timestamp;
      const speed = distance / timeDiff; // px/ms
      totalSpeed += speed;
    }

    return {
      avgSpeed: totalSpeed / (recent.length - 1),
      scrollCount: this.scrollHistory.length,
      lastPosition: this.lastScrollPosition
    };
  }
}

最佳实践指南

防抖最佳实践

  • 默认延迟时间:300-500ms(用户输入)、200-300ms(窗口调整)、1000ms(自动保存)
  • 搜索框建议使用防抖,避免频繁请求
  • 表单验证使用防抖,用户输完再验证
  • 提交按钮使用防抖,防止重复提交
  • 需要立即反馈的操作设置 immediate: true

节流最佳实践

  • 滚动加载:200-300ms(平衡响应性和性能)
  • 拖拽事件:16-33ms(约30-60fps)
  • 窗口大小调整:100-200ms
  • 游戏循环:使用 requestAnimationFrame 替代定时器节流
  • 频繁的状态更新:考虑使用 requestAnimationFrame

内存管理实践

  • 组件卸载时取消未执行的防抖/节流
  • 避免在全局作用域创建过多的防抖/节流函数
  • 使用缓存时注意设置最大缓存大小
  • 定期清理过期的缓存数据

调试技巧

  • 添加日志追踪函数调用
  • 记录调用历史便于回溯问题
  • 使用 Stats 统计调用次数和节流情况
  • 开发环境设置更短的延迟时间便于测试

防抖节流选择决策树

是否需要处理高频事件?
        │
        ├─→ 是
        │   │
        │   ├─→ 是否需要关注最后一次执行?
        │   │   │
        │   │   ├─→ 是 → 使用防抖
        │   │   │   │
        │   │   │   ├─→ 搜索建议、自动保存、表单验证
        │   │   │   └─→ 窗口调整、拖拽结束
        │   │   │
        │   │   └─→ 否 → 使用节流
        │   │       │
        │   │       ├─→ 滚动加载、拖拽中、动画帧
        │   │       └─→ 游戏循环、鼠标移动
        │   │
        │   └─→ 是否需要立即执行?
        │       │
        │       ├─→ 是 → immediate: true
        │       │   │
        │       │   ├─→ 按钮提交(防止双击)
        │       │   └─→ 数据埋点
        │       │
        │       └─→ 否 → immediate: false
        │           │
        │           ├─→ 搜索建议(避免每个字符都请求)
        │           └─→ 自动保存(停止编辑后保存)
        │
        └─→ 否 → 不需要特殊处理

最终建议

  1. 不要盲目使用防抖/节流,先评估是否真的需要
  2. 根据用户体验选择合理的延迟时间
  3. 为防抖/节流函数命名时标明其特性
  4. 在类组件中绑定this时注意上下文
  5. 优先使用成熟的库实现(lodash、underscore)
  6. 理解原理,但不一定需要每次都自己实现
  7. 监控实际效果,根据数据持续优化

结语

防抖和节流是前端性能优化的基本工具,掌握它们不仅能提升应用性能,还能优化用户体验。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

html与CSS伪类技巧

2026年2月16日 05:01

CSS选择器文档

本文档基于15个HTML示例文件介绍了CSS的各种技巧实践,旨在记录开发时候CSS的场景应用。

  1. 影子DOM样式隔离
  2. :not()伪类简化代码
  3. :is()伪类简化选择器
  4. :active伪类实现埋点统计
  5. :focus-within实现搜索交互
  6. <small>标签的使用
  7. <details><summary>实现折叠面板
  8. :placeholder-shown实现输入提示
  9. :default伪类标记默认选项
  10. <label>交互与计数器
  11. 表单校验与反馈
  12. <fieldset><legend>组织表单
  13. :empty伪类处理空内容
  14. :only-child伪类条件显示
  15. :not()伪类初始化样式

1. 影子DOM样式隔离

使用attachShadow()方法创建影子DOM,实现组件的样式隔离,避免样式冲突。

<body>
   <p>外部文字,颜色为黑色</p>
   <div id="hostElement"></div>
</body>

<script>
   const hostElement = document.querySelector('#hostElement')
   const shadow = hostElement.attachShadow({ mode: 'open' })
   shadow.innerHTML = `<p>可以实现组件的样式隔离,颜色为红色</p>`
   shadow.innerHTML += `<style>p{color:red}</style>`
</script>
  • 组件开发,特别是自定义元素
  • 第三方插件集成
  • 样式封装和隔离

2. :not()伪类简化代码

使用:not()伪类排除特定元素,简化CSS选择器,避免重复样式定义。

<ul>
      <li>列表1</li>
      <li>列表2</li>
      <li>列表3</li>
</ul>

<style>
    /*列表最后一项排除*/
    li:not(:last-child){
        border-bottom: 1px solid black;
        padding-bottom:8px;
        margin-bottom: 8px;
    }
</style>
  • 列表项样式处理(如最后一项无边框)
  • 导航菜单样式
  • 表单元素样式排除

3. :is()伪类简化选择器

使用:is()伪类将多个选择器组合成一个,简化CSS代码结构,提高可读性。

<body>
    <header> <a href='#'> 头部字体 </a>  </header>
    <main> <a href='#'> 主题字体 </a>  </main>
    <footer> <a href='#'> 底部字体 </a> </footer>
</body>

<style>
   /* 常见嵌套写法,需经过编译
    header,main,footer{  
       a{   color: red;   } 
    } 
   编译过后 
   header a,main a,footer a{  color: red  } 
   */

    /* :is() 是原生 CSS 语法,无需编译,浏览器直接识别 */
    :is(header, main, footer) a  {   color: red   }
</style>
  • 多个容器内相同元素的样式统一
  • 复杂选择器的简化

4. :active伪类实现埋点统计

使用:active伪类结合content属性,实现无JavaScript的埋点统计功能。

<body>
    <button class="button1">点我上传</button>
    <button class="button2">点我上传2</button>
</body>

<style>
    /* 第一次点击可以触发 */
.button1:active::after{
    content: url(./pixxel.gif?action=click&id=button1);
}
.button2:active::after{
    content: url(./pixxel.gif?action=click&id=button2);
}
</style>
  • 简单的用户行为统计
  • 按钮点击事件追踪
  • 无需JavaScript的埋点方案

5. :focus-within实现搜索交互

使用:focus-within伪类实现父元素在子元素获得焦点时的样式变化,适用于搜索框下拉菜单等交互场景。

<!-- 适合做 搜索框结果的 下拉 -->
<div class="cs-details">
    <a href="javascript:" class="cs-summary">我的消息</a>
    <div class="cs-datalist">
        <a href>我的回答<sup>12</sup></a>
        <a href>我的私信</a>
        <a href>未评价订单</a>
        <a href>我的关注</a>
    </div>
</div>


<style>
    .cs-datalist {
        display: none;
        position: absolute;
        border: 1px solid #000;
        background-color: #fff;
    }

    .cs-details:focus-within .cs-datalist {
        display: block;
    }
</style>
  • 搜索框下拉菜单
  • 导航菜单的子菜单显示
  • 表单元素的关联信息展示

6. <small>标签的使用

使用<small>标签表示小号文本,通常用于免责声明、注释等次要信息。

<small>你好</small>
<div>你好123</div>
  • 法律声明和条款
  • 文章注释和说明
  • 表单字段的辅助信息

7. <details><summary>实现折叠面板

使用<details><summary>标签实现原生的折叠面板功能,无需JavaScript。

<details style="user-select:none;">
    <summary>请选择</summary>
    <ul>
        <li>选项1</li>
        <li>选项2</li>
        <li>选项3</li>
    </ul> 
</details>  
  • 常见问题解答(FAQ)
  • 内容折叠展示
  • 配置选项面板

8. :placeholder-shown实现输入提示

使用:placeholder-shown伪类检测输入框是否显示占位符,实现输入状态的样式变化。

<input type="search" placeholder="请输入内容">
<small>尚未输入内容</small>

<style>
    :not(:placeholder-shown)+small {
        color: transparent;
    }
</style>
  • 输入框的状态提示
  • 表单验证的视觉反馈
  • 提升用户输入体验

9. :default伪类标记默认选项

使用:default伪类标记表单中的默认选项,如默认选中的单选按钮。

<!-- 更换选择,自动补充(推荐) -->
<p>请选择支付方式:</p>
<p><input name="pay" type="radio"><label>支付宝</label></p>
<p><input name="pay" type="radio" checked><label>微信</label></p>
<p><input name="pay" type="radio"><label>银行卡</label></p>

<style>
    input:default+label::after {
        content: '(推荐)';
    }
</style>
  • 表单默认选项标记
  • 推荐选项提示
  • 提高用户表单填写效率

10. <label>交互与计数器

使用<label>标签与复选框关联,结合CSS计数器实现选中项数量统计。

<body>
    <p>请选择你感兴趣的话题:</p>

    <input type="checkbox" id="topic1">
    <label for="topic1" class="cs-topic">科技</label>

    <input type="checkbox" id="topic2">
    <label for="topic2" class="cs-topic">体育</label>

    <input type="checkbox" id="topic3">
    <label for="topic3" class="cs-topic">军事</label>

    <input type="checkbox" id="topic4">
    <label for="topic4" class="cs-topic">娱乐</label>

    <p>您已选择 <span class="cs-topic-counter"></span>个话题。</p>

</body>

<style>
    .cs-topic {
        padding:5px 15px;
        cursor: pointer;
        border: 1px solid #000;
    }
    :checked+.cs-topic {
        border-color: skyblue;
        background-color: azure;
    }
    [type='checkbox'] {
        position: absolute;
        clip: rect(0 0 0 0);
    }

    body {
        counter-reset: topicCounter;
    }

    :checked+.cs-topic {
        counter-increment: topicCounter;
    }

    .cs-topic-counter::before {
        content: counter(topicCounter);
    }
</style>
  • 兴趣标签选择
  • 商品属性选择
  • 多选项表单交互

11. 表单校验与反馈

使用CSS伪类实现表单验证的视觉反馈,包括输入合法、非法和空值状态。

<!-- 表单校验 -->

<form id="csForm" novalidate>
    <p>
        验证码:
        <input class="cs-input" required pattern="\w{4}" placeholder="">
        <span class="cs-vaild-tips"></span>
    </p>

    <input type="submit" />
</form>

<style>
    /* 校验通过 */
    .cs-input:valid {
        background-color: green;
        color: #fff;
    }
    .valid .cs-input:valid+.cs-vaild-tips::before {
        content: '√';
        color: green;
    }

    /* 校验不合法提示 */
    .valid .cs-input:not(:placeholder-shown):invalid {
        border: 2px solid red;
    }
    .valid .cs-input:not(:placeholder-shown):invalid+.cs-vaild-tips::before {
        content: '不符合要求';
        color: red;
    }

    /* 空值提示 */
    .valid .cs-input:placeholder-shown+.cs-vaild-tips::before {
        content: '尚未输入值';
    }
</style>

<script>
    const form = document.querySelector('#csForm')
    const input = document.querySelector('.cs-input')

    // 即时的校验
    // form.addEventListener('input',(e)=>{
    //     form.classList.add('valid')
    // })

    // 优化:输入时实时更新校验提示(可选,提升体验)
    input.addEventListener('input', () => {
        if (form.classList.contains('valid')) {
            // 强制重绘,更新样式
            void form.offsetWidth;
        }
    })

    form.addEventListener('submit', (e) => {
        e.preventDefault()
        form.classList.add('valid') // 触发校验样式

        if (form.checkValidity()) {
            alert('校验通过')
        }
    })
</script>
  • 表单验证反馈
  • 实时输入校验
  • 提升表单填写体验

12. <fieldset><legend>组织表单

使用<fieldset><legend>标签组织表单内容,提高表单的结构性。

<form>
    <fieldset>
        <legend>问卷调查</legend>
        <ol>
            <li>1-3年</li>
            <li>3-5年</li>
            <li>5年以上</li>
            <h4>你从事前端几年了?</h4>
        </ol>
    </fieldset>
</form>
  • 复杂表单的分组
  • 提高表单的可访问性
  • 增强表单的语义结构

13. :empty伪类处理空内容

使用:empty伪类检测元素是否为空,为空白元素添加默认内容或样式。

<dl>
    <dt>姓名:</dt>   <dd>张三</dd>
    <dt>性别:</dt>   <dd></dd>
    <dt>手机:</dt>   <dd></dd>
    <dt>邮箱:</dt>   <dd></dd>
</dl>

<!-- :empty 兼容 ''、null,配合伪元素可填充自定义内容 -->
<style>
    dt {   float: left    }

    dd:empty::before {
        color: gray;
        /* content: '-'; */
        content: '暂无';
    }
</style>
  • 数据展示中的空值处理
  • 搜索结果为空的提示
  • 表单字段的默认显示

14. :only-child伪类条件显示

使用:only-child伪类检测元素是否为唯一子元素,实现条件性的样式显示。

<ul>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
    <li> 仅剩一项时不可删除 <button>删除</button> </li>
</ul>

<style>
    li:only-child button { display: none  }
</style>


<script>
    // 点击删除 li
    const buttons = document.querySelectorAll('li button')
    buttons.forEach(btn => {
        btn.addEventListener('click', function () {
            btn.parentElement.remove()
        })
    })
</script>
  • 列表项的删除按钮控制
  • 条件性UI元素显示
  • 动态内容的样式调整

15. :not()伪类初始化样式

使用:not()伪类排除特定元素,实现样式的初始化和重置。

<!-- 激活面板优雅切换 -->
<!-- 
<div class="cs-panel">面板1</div>
<div class="cs-panel active">面板2</div>
<div class="cs-panel">面板3</div>

<style>  .cs-panel:not(.active) { display: none} </style>
 -->


<!-- 灵活性 -->
<div class="cs-panel">面板1</div>
<div class="cs-panel  flex">面板2</div>
<div class="cs-panel active grid">面板3</div>

<style>
    .cs-panel:not(.active) {  display: none  }
    
    .flex {    display: flex  }
    .grid {    display: grid   }
</style>
  • 选项卡面板切换
  • 激活状态的样式控制
  • 组件的默认状态管理

总结

本文档介绍了CSS新世界中的15个实用技巧,涵盖了样式隔离、选择器优化、表单交互、内容展示等多个方面。这些技巧充分利用了现代CSS的新特性,能够帮助开发者编写更简洁、高效、可维护的代码,同时提升用户体验。

随着CSS标准的不断发展,我们可以期待更多强大的特性和技巧出现。希望本文档能够为开发者提供参考,让大家在CSS的世界中探索更多可能性。

参考资源

  • 张鑫旭《css选择器》

Elpis 动态组件扩展设计:配置驱动的边界与突破

作者 飞雪飘摇
2026年2月15日 22:01

配置驱动的边界问题

Elpis 通过配置驱动解决了 80% 的中后台 CRUD 场景,但总会遇到内置组件无法覆盖的情况:

  • 需要省市区三级联动选择器
  • 需要带千分位格式化的金额输入框
  • 需要集成公司自研的图片裁剪上传组件
  • 需要富文本编辑器、图表组件等第三方库

这时候有三个选择:

方案 A:放弃配置驱动,回到手写代码

方案 B:等框架作者更新内置组件

方案 C:自己扩展组件,像内置组件一样使用

Elpis 选择了方案 C,通过动态组件扩展机制,让框架既保持标准化,又具备灵活性。

核心设计:一个"字符串"的魔法

Elpis 的扩展机制说穿了就一个核心思想:配置里写的是字符串,渲染时才决定用哪个组件

看这段配置:

product_name: {
  createFormOption: {
    comType: 'input',  // 这只是个字符串
  }
}

这个 'input' 不是直接对应某个组件,而是一个"代号"。真正的组件在哪?在一个叫"注册中心"的地方:

// form-item-config.js
const FormItemConfig = {
  'input': { component: InputComponent },
  'select': { component: SelectComponent },
  'richEditor': { component: RichEditorComponent }
};

渲染时,Elpis 做的事情很简单:

<component :is="FormItemConfig[配置里的comType].component" />

就这样,配置和组件解耦了。你想加新组件?往注册中心加一行,配置里就能用。

这个设计妙在哪?

1. 配置稳定: 即使你把 InputComponent 整个重写了,配置文件一个字都不用改。因为配置里只是写了个 'input' 字符串。

2. 场景隔离: 搜索栏有自己的注册中心,表单有自己的注册中心。同样是 'input',在搜索栏可能是个简单输入框,在表单里可能是个带校验的复杂组件。

3. 扩展简单: 不需要改框架代码,不需要发 PR,不需要等更新。自己加一行注册,立刻就能用。

实战:扩展一个富文本编辑器组件

通过实际案例演示如何扩展组件。假设需要添加富文本编辑器支持。

第一步:实现组件

创建文件 app/pages/widgets/schema-form/complex-view/rich-editor/rich-editor.vue

<template>
  <div class="form-item">
    <div class="item-label">
      <span>{{ schema.label }}</span>
      <span v-if="schema.option?.required" class="required">*</span>
    </div>
    <div class="item-value">
      <QuillEditor v-model:content="value" />
      <div v-if="!isValid" class="valid-tips">{{ validMessage }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';

const props = defineProps({
  schemaKey: String,    // 字段名
  schema: Object,       // 字段配置
  model: String         // 初始值
});

const value = ref(props.model || '');
const isValid = ref(true);
const validMessage = ref('');

// 必须实现的接口方法
const validate = () => {
  if (props.schema.option?.required && !value.value) {
    isValid.value = false;
    validMessage.value = '这个字段必填';
    return false;
  }
  isValid.value = true;
  return true;
};

const getValue = () => {
  return { [props.schemaKey]: value.value };
};

defineExpose({ validate, getValue });
</script>

关键约定

  • Props 必须包含 schemaKeyschemamodel
  • 必须暴露 validate()getValue() 方法
  • 其他实现细节可自由发挥

第二步:注册组件

app/pages/widgets/schema-form/form-item-config.js 中注册:

import richEditor from "./complex-view/rich-editor/rich-editor.vue";

const FormItemConfig = {
  input: { component: input },
  select: { component: select },
  richEditor: { component: richEditor }  // 新增注册
};

第三步:配置使用

在业务模型中使用新组件:

product_description: {
  type: 'string',
  label: '商品描述',
  createFormOption: {
    comType: 'richEditor',  // 使用扩展组件
    required: true
  }
}

完成。刷新页面,富文本编辑器自动渲染,校验、提交等功能自动生效。

背后的技术:Vue 3 的动态组件

你可能好奇 Elpis 是怎么做到"运行时决定渲染哪个组件"的。答案是 Vue 3 的 <component :is>

看 Elpis 的核心渲染代码:

<template>
  <template v-for="(itemSchema, key) in schema.properties">
    <component
      :is="FormItemConfig[itemSchema.option?.comType]?.component"
      :schemaKey="key"
      :schema="itemSchema"
      :model="model[key]"
    />
  </template>
</template>

这段代码在做什么?

  1. 遍历配置里的每个字段
  2. 读取字段的 comType(比如 'input'
  3. 从注册中心找到对应的组件(FormItemConfig['input'].component
  4. :is 动态渲染这个组件

关键点:is 后面可以是一个变量,这个变量的值是什么组件,就渲染什么组件。

这就是为什么你改配置文件就能换组件——因为组件是运行时决定的,不是编译时写死的。

一个容易忽略的细节:统一接口

注意到没有,所有组件都接收同样的 props:

:schemaKey="key"
:schema="itemSchema"
:model="model[key]"

这是 Elpis 的"约定"。只要你的组件遵守这个约定,就能被动态渲染。

这就像 USB 接口,不管你是键盘、鼠标还是 U 盘,只要接口对得上,就能插上用。

所以写扩展组件时,记住三件事:

  1. Props 要有 schemaKeyschemamodel
  2. 要暴露 validate()getValue() 方法
  3. 其他的随便你发挥

对比:Elpis vs 其他方案

vs Element Plus / Ant Design(组件库)

组件库:给你一堆组件,你自己拼。

<el-form>
  <el-form-item label="商品名称">
    <el-input v-model="form.name" />
  </el-form-item>
  <el-form-item label="价格">
    <el-input-number v-model="form.price" />
  </el-form-item>
  <!-- 每个字段都要写 -->
</el-form>

Elpis:写个配置,自动生成。

{
  product_name: { createFormOption: { comType: 'input' } },
  price: { createFormOption: { comType: 'inputNumber' } }
}

结论:组件库灵活但重复劳动多,Elpis 标准化但省事。适用场景不同,不是替代关系。

vs Formily / React JSON Schema Form(表单方案)

JSON Schema 表单:只管表单,其他的你自己搞。

Elpis:搜索 + 表格 + 表单 + 详情,一套配置全搞定。

结论:Elpis 是 JSON Schema 思想在整个中后台系统的延伸。

写在最后

Elpis 的动态组件扩展机制核心就三件事:

  1. 配置里写字符串标识,不直接引用组件
  2. 用注册中心做类型映射,字符串对应具体组件
  3. 用 Vue 的 :is 实现运行时动态渲染

这套设计让框架在标准化和灵活性之间找到了平衡:

  • 80% 的场景用内置组件,配置驱动,快速开发
  • 20% 的场景扩展组件,一次封装,到处复用

扩展组件的成本是一次性的,但收益是长期的。当你的组件库逐渐丰富,配置驱动的威力就会越来越明显。

框架的价值不在于限制开发者,而在于提供清晰的扩展路径,让开发者在需要时能够突破标准化的边界。

引用: 抖音“哲玄前端”《大前端全栈实践》

【翻译】Rolldown工作原理:模块加载、依赖图与优化机制全揭秘

2026年2月15日 21:59

原文链接:www.atriiy.dev/blog/rolldo…

作者: Atriiy

引言

Rolldown 是一款基于 Rust 开发的极速 JavaScript 打包工具,专为无缝兼容 Rollup API 设计。其核心目标是在不久的将来成为 Vite 的统一打包器,为 Vite 提供底层支撑。目前 Vite 在本地开发阶段依赖 esbuild 实现极致的构建速度,而生产环境构建则基于 Rollup;切换为 Rolldown 这类单一打包器后,有望简化整个构建流程,让开发者更有信心 —— 开发环境所见的效果,与生产环境最终上线的结果完全一致。此外,Rolldown 的打包速度预计比 Rollup 快 10~30 倍。想了解更多细节?可查阅 Rolldown 官方文档。

本文将先从 Rolldown 的整体架构入手,帮你建立对其工作原理的全局认知,避免过早陷入细节而迷失方向。在此基础上,我们会深入本文的核心主题:模块加载器 —— 这是 Rolldown 扫描阶段的核心组件,我们将剖析其关键功能,以及支撑它运行的重要数据结构。

接下来,我们还会探讨依赖图,以及 Rolldown 采用的部分性能优化策略。尽管其中部分内容你可能此前有所接触,但结合上下文重新梳理仍有价值 —— 这些内容是理解 Rolldown 如何实现极致速度与效率的关键。

好了,让我们正式走进 Rolldown 的世界吧。😉

Rolldown 整体架构概览

Rolldown 的核心流程分为四个主要步骤:依赖图构建 → 优化 → 代码生成 / 打包 → 输出。最终生成的打包产物会根据场景(本地开发 / 生产构建)写入内存或文件系统。你可在 crates/rolldown/src/bundler.ts 路径下找到入口模块的实现。以下是该流程的示意图:

graph TD
start["Start: Read Config & Entry Points"]
parse["Parse Entry Module"]
build{"Build Module Graph"}
load["Load & Parse Dependency Modules"]
optimaze["Code optimization"]
generate["Code Generation: Generate\n Chunks"]
return["Return Output Assets In\n Memory"]
write["Write Output Files to Disk"]
start --> parse
parse --> build
build -->|Scan Dependencies|load
load -->|Repeat until all\n dependencies are processed|build
build --> optimaze
optimaze --> generate
generate -->|Generate Mode: rolldown.generate| return
generate -->|Write Mode: rolldown.write| write

模块加载器是构建模块依赖图阶段的核心组件,它由 Bundler 结构体中的 scan 函数触发调用。为实现更清晰的职责分离,整个扫描流程已被封装到专用的 ScanStage 结构体中。

但真正的核心工作都发生在 ModuleLoader(模块加载器)内部:它负责处理构建依赖图、解析单个模块等关键任务,也是 Rolldown 中大量核心计算逻辑的落地之处 —— 这正是本文要重点探讨的内容。

模块加载器(Module Loader)

简而言之,模块加载器的核心职责是定位、获取并解析单个模块(包括源码文件、CSS 文件等各类资源),并将这些模块转换为打包器能够识别和处理的内部数据结构。这一步骤是构建精准且高效的模块依赖图的关键。

以下示意图展示了 Rolldown 在打包流程中如何使用模块加载器:

graph TD
prepare["Bundler: Prepare needs to\n Build Module Graph"]
create["Create Module Loader\n Instance"]
calls["Bundler: Calls Module\n Loader's fetch_modules"]
load[["Module Loader Operation"]]
return["Module Loader: Returns\n Aggregated Results to\n Bundler"]
result["Bundler: Uses Results for\n Next Steps - e.g., Linking,\n Optimization, Code Gen"]
prepare --> create
create --> calls
calls --> load
load --> return
return --> result

上述所有步骤均发生在 ScanStage 结构体的 scan 函数内部。你可以将 scan 函数理解为一个编排器(builder) —— 它统筹并封装了运行模块加载器所需的全部逻辑。

拉取模块(Fetch modules)

fetch_modules 是整个流程的 “魔法起点”。它扮演着调度器(scheduler) 的角色,启动一系列异步任务来解析所有相关模块。该函数负责处理用户定义的入口点 —— 这也是模块扫描算法的起始位置。

在进入 fetch_modules 之前,scan 函数会先解析这些入口点,并将其转换为 Rolldown 内部的 ResolvedId 结构体。这一预处理步骤由 resolve_user_defined_entries 函数完成。

以下示意图展示了 fetch_modules 函数的核心工作流程:

graph TD
start["Start: Receive Resolved\n User Defined Entries"]
init["Initialize: Task Counter,\n Module Cache, Result\n Collectors"]
launch["Launch Async Tasks for Each\n Entry"]
loop{"Message Loop: Listen while\n Task Counter > 0"}
store["Store Results;\nProcess Dependencies"]
request["Launch Task for Requested\n Module; Increment Counter"]
resolve["Resolve & Launch Task for\n New Entry; Increment\n Counter"]
record["Record Error; Decrement\n Counter"]
depence{"New Dependencies?"}
tasks["Launch Tasks for New\n Dependencies; Increment\n Counter"]
dec["Decrement Task Counter"]
zero{"Task Counter == 0?"}
final["Finalize: Update\n Dependency Graph,\n Organize Results"]
return["End: Return Output to\n Caller"]
start --> init
init --> launch
launch --> loop
loop -->|Module Done| store
loop -->|Plugin Fetch Module| request
request --> loop
loop -->|Plugin Add Entry| resolve
resolve --> loop
loop -->|Build Error| record
record --> loop
store --> depence
depence -->|Yes| tasks
tasks --> loop
depence -->|No| dec
dec --> zero
zero -->|No| loop
zero -->|Yes| final
final --> return

看起来有点复杂,对吧?这是因为该阶段集成了大量优化策略和功能特性。不过别担心 —— 我们可以暂时跳过细枝末节,先聚焦整体流程。

如前文所述,fetch_modules 函数以解析后的用户定义入口点为输入,开始执行处理逻辑。对于每个入口点,它会调用 try_spawn_new_task 函数:该函数先判定模块属于内部模块还是外部模块,再执行对应的处理逻辑,最终返回一个类型安全的 ModuleIdx(模块索引) 。这个索引后续会作为整个系统中引用对应模块的唯一标识。

当所有入口点的初始任务都已启动后,fetch_modules 会进入循环,监听一个基于 tokio::sync::mpsc 实现的消息通道。每个模块处理任务都持有该通道的发送端句柄(sender handle),并向主进程上报事件。fetch_modules 内部的消息监听器会响应这些消息,具体包括以下类型:

  • 普通 / 运行时模块处理完成:存储处理结果,并调度该模块的所有依赖模块;
  • 拉取模块:响应插件的按需加载特定模块请求;
  • 添加入口模块:在扫描过程中新增入口点(通常由插件触发);
  • 构建错误:捕获加载或转换过程中出现的所有错误。

当所有模块处理完毕且无新消息传入时,消息通道会被关闭,循环随之退出。随后 fetch_modules 执行收尾清理工作:存储已处理的入口点、更新依赖图,并将聚合后的结果返回给调用方(即 scan 函数)。该结果包含模块、抽象语法树(AST)、符号、入口点、警告信息等核心数据 —— 这些都会被用于后续的优化和代码生成阶段。

启动新任务(Spawn new task)

try_spawn_new_task 函数首先尝试从模块加载器的缓存中获取 ModuleIdx(模块索引)。由于扫描阶段本质上是对依赖图的遍历过程,该缓存通过哈希映射表跟踪每个模块的访问状态 —— 其中键为模块 ID,值用于标识该模块是否已处理完成。

接下来,函数会根据模块类型,将其转换为普通模块或外部模块结构,以便进行后续处理。理解外部模块的处理逻辑尤为重要:这类模块不会被 Rolldown 打包 —— 它们预期由运行时环境提供(例如 node_modules 中的第三方库)。尽管不会被纳入最终打包产物,但 Rolldown 仍会记录其元数据,实际上是将其视为占位符(placeholder) 。打包产物会假定这些模块在运行时可用,并在需要时直接引用它们。

而普通模块(通常由用户编写)的处理方式则不同:try_spawn_new_task 会为每个普通模块创建一个专属的模块任务,并以异步方式执行。这些任务由 Rust 异步运行时 Tokio 管理。如前文所述,每个任务都持有消息通道的发送端,可在运行过程中上报错误、新发现的导入项,或动态添加的入口点。

数据结构(Data structures)

为提升性能和代码复用性,Rolldown 大量使用专用数据结构。理解模块加载器中几个核心数据结构的设计,能让你更清晰地认知扫描流程的底层实现逻辑。

ModuleIdx & HybridIndexVec

ModuleIdx 是一种自定义数值索引,会在模块处理过程中动态分配。这种索引设计兼顾类型安全与性能:Rolldown 不会传递或克隆完整的模块结构体,而是使用这种轻量级标识符(类似其他编程语言中的指针),在整个系统中引用模块。

pub struct ModuleIdx = u32;

HybridIndexVec 是 Rolldown 用于存储模块数据的智能自适应容器。由于 Rolldown 核心操作的对象是 ModuleIdx(模块索引)而非实际的模块数据,实现高效的 “基于 ID 查找” 就至关重要 —— 而这正是 HybridIndexVec 的设计初衷:它会针对不同的打包场景做针对性优化。

pub enum HybridIndexVec<I: Idx, T> {
  IndexVec(IndexVec<I, T>),
  Map(FxHashMap<I, T>),
}

打包工具通常运行在两种模式下:

  • 全量打包(Full bundling) (生产环境构建的主流模式):所有模块仅扫描一次,并以连续存储的方式保存。针对这种场景,Rolldown 采用名为 IndexVec紧凑高性能结构—— 它的行为类似向量(vector),但强制要求类型安全的索引访问。
  • 增量打包(Partial bundling) (常用于开发环境):模块依赖图可能频繁变化(例如开发者编辑文件时)。这种场景下,稀疏结构(sparse structure)更适用,Rolldown 会使用基于 FxHash 算法的哈希映射表,以实现高效的键值对访问。

FxHash 算法比 Rust 默认哈希算法更快,尽管其哈希冲突的概率略高。由于键和值均由 Rolldown 内部管理,且 “可预测的性能” 比安全性更重要,因此这种取舍对于 Rolldown 的使用场景而言是可接受的。

模块(Module)

普通模块由用户定义 —— 通常是需要解析、转换或分析的源码文件。Rolldown 会加载这些文件,并根据文件扩展名进行处理。例如,.ts(TypeScript)文件会通过高性能的 JavaScript/TypeScript 解析器 Oxc 完成解析。

pub enum Module {
  Normal(Box<NormalModule>),
  External(Box<ExternalModule>),
}

内部的 NormalModule 结构体存储着每个模块的详细信息:既包含 idx(索引)、module_type(模块类型)等基础元数据,也涵盖模块内容的富表示形式(richer representations) 。根据文件类型的不同,这些内容具体包括:

  • ecma_view:用于 JavaScript/TypeScript 模块
  • css_view:用于样式表文件
  • asset_view:用于静态资源文件

这种结构化设计,能让打包流程后续阶段(如优化、代码生成)高效处理已解析的模块内容。

ScanStageCache(扫描阶段缓存)

这是一个在模块加载过程中存储所有缓存数据的结构体。以下是该数据结构的定义:

pub struct ScanStageCache {
  snapshot: Option<NormalizedScanStageOutput>,
  pub module_id_to_idx: FxHashMap<ArcStr, VisitState>,
  pub importers: IndexVec<ModuleIdx, Vec<ImporterRecord>>,
}

snapshot(快照)存储着上一次扫描阶段的执行结果,用于支持增量构建。Rolldown 无需从头重新扫描所有模块,而是复用上次扫描的部分结果 —— 当仅有少量文件变更时,这一机制能大幅缩短构建耗时。

module_id_to_idx 是一个哈希映射表,存储模块 ID 与其访问状态的映射关系。程序可通过它快速判断某个模块是否已处理完成。

该映射表的键类型为 ArcStr—— 这是一种内存高效、支持引用计数的字符串类型,专为跨线程共享场景优化。更重要的是,这个字符串是模块的全局唯一且稳定的标识符,在多次构建过程中保持一致,这对缓存的可靠性至关重要。

importers 是模块依赖图的反向邻接表:针对每个模块,它会跟踪 “哪些其他模块导入了该模块”。这在增量构建中尤为实用:当某个模块内容变更时,importers 能帮助 Rolldown 快速确定受影响模块的范围 —— 本质上就是识别出需要重新处理的模块。

需注意,importers 还会有一个临时版本存储在 IntermediateNormalModules(中间普通模块)中。你可以将其理解为 “草稿状态”,会在当前构建过程中动态生成。

依赖图(Dependency graph)

依赖图描述了模块间的相互依赖关系,也是扫描阶段最重要的输出之一。Rolldown 会在后续阶段(如摇树优化、代码分块、代码生成)利用这份关系映射表完成各类核心任务。

在深入讲解具体实现前,我们先介绍邻接表的概念 —— 它是依赖图的表示与遍历的核心载体。

图与邻接表(Graph and Adjacency table)

众所周知,图是用于表示 “事物间关联关系” 的数据结构,由两部分组成:

  • 节点(Nodes):被关联的项或实体(对应 Rolldown 中的模块)
  • 边(Edges):节点之间的关联或依赖关系(对应模块间的导入导出关系)

图有两种常见的表示方式:邻接矩阵邻接表

邻接矩阵是一个二维网格(矩阵),每行和每列对应一个节点。矩阵中某个单元格的值表示两个节点之间是否存在边:例如,值为 1 表示存在关联,值为 0 则表示无关联。

  | A | B | C
A | 0 | 1 | 0
B | 1 | 0 | 1
C | 0 | 1 | 0

这种方式(邻接矩阵)简单直观,在稠密图场景下表现优异 —— 即大多数节点之间都存在关联的图。但对于稀疏图而言,它的内存利用率极低,而 Rolldown 这类打包工具中的模块依赖图恰好属于稀疏图。(相信没人会在项目里把所有模块都导入到每一个文件中吧。😉)

邻接表则是另一种存储方式:每个节点都维护一个 “邻居节点列表”。它不会使用固定大小的矩阵,而是只存储实际存在的关联关系,因此在稀疏图场景下效率更高。

举个例子:若节点 A 关联到节点 B,节点 B 关联到节点 A 和 C,最终节点 C 仅关联到节点 B。

A[B]
B[A, C]
C → [B]

这种结构(邻接表)内存利用率高,且能轻松适配大型稀疏图场景 —— 比如 Rolldown 这类打包工具所处理的模块依赖图。同时,它还能让程序仅遍历相关的关联关系,这一点在扫描或优化阶段尤为实用。

正向与反向依赖图(Forward & reverse dependency graph)

在扫描阶段,Rolldown 会构建两种类型的依赖图:正向依赖图和反向依赖图。其中,正向依赖图存储在每个模块的 ecma_view 中,记录当前模块所导入的其他模块。

pub struct ecma_view {
  pub import_records: IndexVec<ImportRecordIdx, ResolvedImportRecord>,
  // ...
}

正向依赖图对打包至关重要。模块加载器从用户定义的入口点出发,构建这张图来确定最终打包产物需要包含哪些模块。它在确定执行顺序管理变量作用域方面也扮演着关键角色。

此外,模块加载器还会创建一张反向依赖图,方便追踪哪些模块导入了指定模块。这对摇树优化(Tree Shaking)、副作用分析、增量构建、代码分块和代码分割等功能至关重要。

这些功能涉及大量上下文,这里就不展开细讲。你可以简单这样理解:如果我(某个模块)发生了变化,谁会受到影响? 答案是:所有依赖这个变更模块的模块都需要重新处理。这就是实现增量构建热模块替换(HMR) 的核心思想。

性能优化

Rolldown 底层包含大量性能优化手段。得益于 Rust 的零成本抽象所有权模型,再搭配 Tokio 强大的异步运行时,开发者拥有了将性能推向新高度的工具。模块加载器本身也运用了多种提速技术,这里我们简要介绍一下,大部分内容前面已经提到过。

异步并发处理

并发是模块加载器的核心。如前所述,它的主要职责是遍历所有模块并构建依赖图。在实际项目中,导入关系会迅速变得复杂且嵌套很深,这使得异步并发至关重要。

在 Rust 中,asyncawait 是异步函数的基础构建块。异步函数会返回一个 Future,它不会立即执行,只有在显式 await 时才会运行。Rolldown 基于 Rust 最主流的异步运行时 Tokio,高效并发地执行这些模块处理任务。

缓存

由于 Rolldown 会执行大量异步操作,并且在本地开发环境中会频繁重复运行,缓存就成了避免重复工作的关键。

模块加载器的缓存存放在 ModuleLoader 结构体内部,包含 snapshotmodule_id_to_idximporters 等数据,大部分我们在前面章节已经介绍过。这些缓存能帮助 Rolldown 避免重复处理相同模块,让增量构建速度大幅提升。

未来展望

Rolldown 仍在积极开发中。未来,它有望成为 Vite 的底层引擎,提供一致的构建结果极致的性能。你可以在这里查看路线图。

我写这篇文章是为了记录我研究 Rolldown 的过程,也希望能为你揭开它那些出色底层实现的神秘面纱。如果你发现错误或觉得有遗漏,欢迎在下方留言 —— 我非常期待你的反馈!😊

感谢阅读,我们下篇文章见!

❌
❌