普通视图

发现新文章,点击刷新页面。
今天 — 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 理解底层技术原语的设计目的、取舍原则和发展前景。掌握这些底层知识,永远有其不可替代的价值。

Chmod Cheatsheet

Basic Syntax

Use these core command forms for chmod.

Command Description
chmod MODE FILE General chmod syntax
chmod 644 file.txt Set numeric permissions
chmod u+x script.sh Add execute for owner
chmod g-w file.txt Remove write for group
chmod o=r file.txt Set others to read-only

Numeric Modes

Common numeric permission combinations.

Mode Meaning
600 Owner read/write
644 Owner read/write, group+others read
640 Owner read/write, group read
700 Owner full access only
755 Owner full access, group+others read/execute
775 Owner+group full access, others read/execute
444 Read-only for everyone

Symbolic Modes

Change specific permissions without replacing all bits.

Command Description
chmod u+x file Add execute for owner
chmod g-w file Remove write for group
chmod o-rwx file Remove all permissions for others
chmod ug+rw file Add read/write for owner and group
chmod a+r file Add read for all users
chmod a-x file Remove execute for all users

Files and Directories

Typical permission patterns for files and directories.

Command Description
chmod 644 file.txt Standard file permissions
chmod 755 dir/ Standard executable directory permissions
chmod u=rw,go=r file.txt Symbolic equivalent of 644
chmod u=rwx,go=rx dir/ Symbolic equivalent of 755
chmod +x script.sh Make script executable

Recursive Changes

Apply permission updates to directory trees.

Command Description
chmod -R 755 project/ Recursively set mode for all entries
chmod -R u+rwX project/ Add read/write and smart execute recursively
find project -type f -exec chmod 644 {} + Set files to 644
find project -type d -exec chmod 755 {} + Set directories to 755
chmod -R g-w shared/ Remove group write recursively

Special Bits

Setuid, setgid, and sticky bit examples.

Command Description
chmod 4755 /usr/local/bin/tool Setuid on executable
chmod 2755 /srv/shared Setgid on directory
chmod 1777 /tmp/mytmp Sticky bit on world-writable directory
chmod u+s file Add setuid (symbolic)
chmod g+s dir Add setgid (symbolic)
chmod +t dir Add sticky bit (symbolic)

Safe Patterns

Use these patterns to avoid unsafe permission changes.

Command Description
chmod 600 ~/.ssh/id_ed25519 Secure SSH private key
chmod 700 ~/.ssh Secure SSH directory
chmod 644 ~/.ssh/id_ed25519.pub Public key permissions
chmod 750 /var/www/app Limit web root access
chmod 755 script.sh Safer than 777 for scripts

Common Errors

Quick checks when permission changes do not work.

Issue Check
Operation not permitted Check file ownership with ls -l and apply with the correct user or sudo
Permission still denied after chmod Parent directory may block access; check directory execute (x) bit
Cannot chmod symlink target as expected chmod applies to target file, not link metadata
Recursive mode broke app files Reset with separate file/dir modes using find ... -type f/-type d
Changes revert on mounted share Filesystem mount options/ACL may override mode bits

Related Guides

Use these guides for full permission and ownership workflows.

Guide Description
How to Change File Permissions in Linux (chmod command) Full chmod guide with examples
Chmod Recursive: Change File Permissions Recursively in Linux Recursive permission strategies
What Does chmod 777 Mean Security impact of 777
Chown Command in Linux (File Ownership) Change file and directory ownership
Umask Command in Linux Default permissions for new files
Understanding Linux File Permissions Permission model explained

【节点】[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,你的电子学友。

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

简单题,简单做(Python/Java/C++/Go)

作者 endlesscheng
2026年2月17日 07:29

枚举小时 $h=0,1,2,\ldots,11$ 以及分钟 $m=0,1,2,\ldots,59$。如果 $h$ 二进制中的 $1$ 的个数加上 $m$ 二进制中的 $1$ 的个数恰好等于 $\textit{turnedOn}$,那么把 $h:m$ 添加到答案中。

注意如果 $m$ 是个位数,需要添加一个前导零。

class Solution:
    def readBinaryWatch(self, turnedOn: int) -> List[str]:
        ans = []
        for h in range(12):
            for m in range(60):
                if h.bit_count() + m.bit_count() == turnedOn:
                    ans.append(f"{h}:{m:02d}")
        return ans
class Solution {
    public List<String> readBinaryWatch(int turnedOn) {
        List<String> ans = new ArrayList<>();
        for (int h = 0; h < 12; h++) {
            for (int m = 0; m < 60; m++) {
                if (Integer.bitCount(h) + Integer.bitCount(m) == turnedOn) {
                    ans.add(String.format("%d:%02d", h, m));
                }
            }
        }
        return ans;
    }
}
class Solution {
public:
    vector<string> readBinaryWatch(int turnedOn) {
        vector<string> ans;
        char s[6];
        for (uint8_t h = 0; h < 12; h++) {
            for (uint8_t m = 0; m < 60; m++) {
                if (popcount(h) + popcount(m) == turnedOn) {
                    sprintf(s, "%d:%02d", h, m);
                    ans.emplace_back(s);
                }
            }
        }
        return ans;
    }
};
func readBinaryWatch(turnedOn int) (ans []string) {
    for h := range 12 {
        for m := range 60 {
            if bits.OnesCount8(uint8(h))+bits.OnesCount8(uint8(m)) == turnedOn {
                ans = append(ans, fmt.Sprintf("%d:%02d", h, m))
            }
        }
    }
    return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。
  • 空间复杂度:$\mathcal{O}(1)$。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

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月17日 00:00

二进制手表顶部有 4 个 LED 代表 小时(0-11),底部的 6 个 LED 代表 分钟(0-59)。每个 LED 代表一个 0 或 1,最低位在右侧。

  • 例如,下面的二进制手表读取 "4:51"

给你一个整数 turnedOn ,表示当前亮着的 LED 的数量,返回二进制手表可以表示的所有可能时间。你可以 按任意顺序 返回答案。

小时不会以零开头:

  • 例如,"01:00" 是无效的时间,正确的写法应该是 "1:00"

分钟必须由两位数组成,可能会以零开头:

  • 例如,"10:2" 是无效的时间,正确的写法应该是 "10:02"

 

示例 1:

输入:turnedOn = 1
输出:["0:01","0:02","0:04","0:08","0:16","0:32","1:00","2:00","4:00","8:00"]

示例 2:

输入:turnedOn = 9
输出:[]

 

提示:

  • 0 <= turnedOn <= 10

401. 二进制手表,回溯,Java0ms

作者 edelweisskoko
2021年3月12日 12:12

401. 二进制手表


思路

简单提一句,回溯就是纯暴力枚举,配合剪枝食用风味更佳

  • 总体思路
  1. 在10个灯中选num个灯点亮,如果选择的灯所组成的时间已不合理(小时超过11,分钟超过59)就进行剪枝
  2. 也就是从0到10先选一个灯亮,再选当前灯的后面的灯亮,再选后面的灯的后面的灯亮,一直到num个灯点满
  • 具体思路
  1. 为了方便计算,分别设置了小时数组和分钟数组
  2. 递归的四个参数分别代表:剩余需要点亮的灯数量,从索引index开始往后点亮灯,当前小时数,当前分钟数
  3. 每次进入递归后,先判断当前小时数和分钟数是否符合要求,不符合直接return
  4. for循环枚举点亮灯的情况,从index枚举到10,每次枚举,
    • 减少一个需要点亮的灯数量num - 1
    • 从当前已点亮的灯后面选取下一个要点亮的灯 i + 1
    • 在hour中增加当前点亮灯的小时数,如果i大于3,当前灯是分钟灯而不是小时灯,则加上0个小时
    • 在minute中增加当前点亮灯的分钟数,如果i没有大于3,当前灯是小时灯而不是分钟灯,则加上0分钟
  5. 当剩余需要点亮的灯数量为0的时候,已枚举完一种情况,根据题目要求的格式加到res列表中
  6. 返回res

代码

class Solution:
    def readBinaryWatch(self, num: int) -> List[str]:
        hours = [1, 2, 4, 8, 0, 0, 0, 0, 0, 0]
        minutes = [0, 0, 0, 0, 1, 2, 4, 8, 16, 32]
        res = []
        def backtrack(num, index, hour, minute):
            if hour > 11 or minute > 59:
                return
            if num == 0:
                res.append('%d:%02d' % (hour, minute))
                return
            for i in range(index, 10):
                backtrack(num - 1, i + 1, hour + hours[i], minute + minutes[i])
        
        backtrack(num, 0, 0, 0)
        return res
class Solution {
    int[] hours = new int[]{1, 2, 4, 8, 0, 0, 0, 0, 0, 0};
    int[] minutes = new int[]{0, 0, 0, 0, 1, 2, 4, 8, 16, 32};
    List<String> res = new ArrayList<>();

    public List<String> readBinaryWatch(int num) {
        backtrack(num, 0, 0, 0);
        return res;
    }

    public void backtrack(int num, int index, int hour, int minute){
        if(hour > 11 || minute > 59) 
            return;
        if(num == 0){
            StringBuilder sb = new StringBuilder();
            sb.append(hour).append(':');
            if (minute < 10) {
                sb.append('0');
            }
            sb.append(minute);
            res.add(sb.toString());
            return;
        }
        for(int i = index; i < 10; i++){
            backtrack(num - 1, i + 1, hour + hours[i], minute + minutes[i]);
        }  
    }
}

复杂度分析

  • 时间复杂度:$O(C^{num}_{10})$ 从10个选num个,实际比这个低因为剪枝了
  • 空间复杂度:$O(num)$

C++总结了回溯问题类型 带你搞懂回溯算法(搜索篇)

2020年5月4日 02:03

在上一篇题解中,我总结了回溯算法的三种类型,以及什么时候用回溯算法,怎么写回溯算法,如果没看过的,强烈建议先看:C++ 总结了回溯问题类型 带你搞懂回溯算法(大量例题)

这一节,我们就来解析“搜索”类型的回溯问题。

为什么要单独分出一种“搜索”类型?

其实,“搜索”类型的题解中都能看到“子集”、“排列”类型题目的影子,只要你搞懂前面两种类型问题的本质,就很容易联想到了。“搜索”类型的问题最难的就是把问题抽象化!!大多数比赛或者面试题中不会直接出现“子集、排列、组合”等字眼,更不会直接让去求,而是通过某些包装,借助某个情景,让你一下子摸不着头脑,看不出应该使用哪种解法,本题就是最好的说明

解题步骤:
1.读懂题意,把题目尽可能抽象成“子集、排列、组合”类型问题
本题的题目总结而言就是:有十盏灯,我分别给他编号0-9,号码0-3代表小时,号码4-9代表分钟,然后每盏灯又代表着不同数字,如下图
image.png
(灯泡中的红色数字,其实就是二进制转换,题目中的图也有标)
然后从10盏灯中挑选n盏打开,问你有多少种组合,返回这些组合代表的时间。这样一翻译,是不是有点“组合”类型问题的味道了?说白了就是:从n个数里面挑选k个,返回组合。如果你能形成这种抽象思想,那么你就成功一大半了。

2.回溯六步走

  • ①画出递归树,找到状态变量(回溯函数的参数),这一步非常重要
  • ②根据题意,确立结束条件**
  • ③找准选择列表(与函数参数相关),与第一步紧密关联
  • ④判断是否需要剪枝**
  • ⑤作出选择,递归调用,进入下一层
  • ⑥撤销选择

3.开始解题
①递归树,这里用动画演示。假设n=2,即选两盏灯亮
<1.png,2.png,3.png,4.png,222.png,5.png,6.png,7.png,8.png,9.png,10.png,11.png,12.png>

我们接下来思考,回溯函数需要什么哪些参数,来表示当前状态。首先灯是越来越少的,所以需要一个参数num,表示剩余可亮的灯数,直到num等于0就应该结束;然后还需要参数start,来指示当前可选择的灯的开始位置,比如n=2我选了7号灯,然后剩下的一盏灯,只能选8号或9号,你不可能回去选0-6号,因为会导致重复,这个问题在“子集、组合”类型问题中说过,详细请看顶部的文章;最后还需要一个time参数,来表示当前状态的时间。算法签名如下

###cpp

unordered_map<int,int> hash={{0,1},{1,2},{2,4},{3,8},{4,1},{5,2},{6,4},{7,8},{8,16},{9,32}};//用一个hash表映射,灯对应的数字
void backtrack(int num,int start,pair<int,int>& time)//我用stl中的pair结构来统一管理小时和分钟,代码比较简洁,你也可以把他们拆成两个参数,hour和minute

②根据题意,确立结束条件
这个上面提到过了,当num=0就是没有灯可以点亮了,就应该return,加入结果集

###cpp

if(num==0)
        {
            if(time.first>11||time.second>59)//判断合法性,注意题目要求
                return;
            string temp_hour=to_string(time.first);
            string temp_minute=to_string(time.second);
            if(temp_minute.size()==1)//如果minute只有一位要补0,注意题目要求
                temp_minute.insert(0,"0");
            res.push_back(temp_hour+":"+temp_minute);//构造格式
            return;
        }

③找准选择列表
从参数start标识的位置开始,到最后一盏灯,都是可选的

###cpp

for(int i=start;i<10;i++)
{


}

④判断是否需要剪枝
当hour>11或minute>59的时候,无论还有没有灯可以点亮,都不用再继续递归下去,因为后面只会越增越多,应该剪枝

###cpp

for(int i=start;i<10;i++)
{
    if(time.first>11||time.second>59)
         continue;
}

⑤作出选择,递归调用,进入下一层

###cpp

 for(int i=start;i<10;i++)
    {
        if(time.first>11||time.second>59)
            continue;
        pair<int,int>store=time;//保存状态,用于回溯时,恢复当前状态
        if(i<4)//对小时数进行操作
            time.first+=hash[i];
        else//对分钟数进行操作
            time.second+=hash[i];
        backtrack(num-1,i+1,time);//进入下一层,注意下一层的start是i+1,即从当前灯的下一盏开始

⑥撤销选择
整体代码如下

###cpp

vector<string>res;
    unordered_map<int,int> hash={{0,1},{1,2},{2,4},{3,8},{4,1},{5,2},{6,4},{7,8},{8,16},{9,32}};
    void backtrack(int num,int start,pair<int,int>& time)
    {
        if(num==0)
        {
            if(time.first>11||time.second>59)//判断合法性
                return;
            string temp_hour=to_string(time.first);
            string temp_minute=to_string(time.second);
            if(temp_minute.size()==1)//如果minute只有一位要补0
                temp_minute.insert(0,"0");
            res.push_back(temp_hour+":"+temp_minute);//构造格式
            return;
        }
    
        for(int i=start;i<10;i++)
        {
            if(time.first>11||time.second>59)
                continue;
            pair<int,int>store=time;//保存状态
            if(i<4)
                time.first+=hash[i];
            else
                time.second+=hash[i];
            backtrack(num-1,i+1,time);//进入下一层,注意下一层的start是i+1,即从当前灯的下一盏开始
            time=store;//恢复状态
        }
    }
    vector<string> readBinaryWatch(int num) {
        pair<int,int>time(0,0);//初始化时间为0:00
        backtrack(num,0,time);
        return res;
    }

C++:简简单单的几行代码解决问题

作者 ljj666
2019年6月29日 20:09

QQ截图20190629200856.png

class Solution {
public:
    vector<string> readBinaryWatch(int num) {
        vector<string> res;
        //直接遍历  0:00 -> 12:00   每个时间有多少1
        for (int i = 0; i < 12; i++) {
            for (int j = 0; j < 60; j++) {
                if (count1(i) + count1(j) == num) {
                    res.push_back(to_string(i)+":"+
                                  (j < 10 ? "0"+to_string(j) : to_string(j)));
                }
            }
        }
        return res;
    }
    //计算二进制中1的个数
    int count1(int n) {
        int res = 0;
        while (n != 0) {
            n = n & (n - 1);
            res++;
        }
        return res;
    }
};
昨天 — 2026年2月16日技术

Best Linux Distributions for Every Use Case

A Linux distribution (or “distro”) is an operating system built on the Linux kernel, combined with GNU tools, libraries, and software packages. Each distro includes a desktop environment, package manager, and preinstalled applications tailored to specific use cases.

With hundreds of Linux distributions available, choosing the right one can be overwhelming. This guide covers the best Linux distributions for different types of users, from complete beginners to security professionals.

How to Choose a Linux Distro

When selecting a Linux distribution, consider these factors:

  • Experience level — Some distros are beginner-friendly, while others require technical knowledge
  • Hardware — Older computers benefit from lightweight distros like Xubuntu and other Xfce-based systems
  • Purpose — Desktop use, gaming, server deployment, or security testing all have ideal distros
  • Software availability — Check if your required applications are available in the distro’s repositories
  • Community support — Larger communities mean more documentation and help

Linux Distros for Beginners

These distributions are designed with user-friendliness in mind, featuring intuitive interfaces and easy installation.

Ubuntu

Ubuntu is the most popular Linux distribution and an excellent starting point for newcomers. Developed by Canonical, it offers a polished desktop experience with the GNOME environment and extensive hardware support.

Ubuntu desktop screenshot

Ubuntu comes in several editions:

  • Ubuntu Desktop — Standard desktop with GNOME
  • Ubuntu Server — For server deployments
  • Kubuntu, Lubuntu, Xubuntu — Alternative desktop environments

Ubuntu releases new versions every six months, with Long Term Support (LTS) versions every two years receiving five years of security updates.

Website: https://ubuntu.com/

Linux Mint

Linux Mint is an excellent choice for users coming from Windows. Its Cinnamon desktop environment provides a familiar layout with a taskbar, start menu, and system tray.

Linux Mint desktop screenshot

Key features:

  • Comes with multimedia codecs preinstalled
  • LibreOffice productivity suite included
  • Update Manager for easy system maintenance
  • Available with Cinnamon, MATE, or Xfce desktops

Website: https://linuxmint.com/

Pop!_OS

Developed by System76, Pop!_OS is based on Ubuntu but optimized for productivity and gaming. It ships with the COSMIC desktop environment (built in Rust by System76), featuring built-in window tiling, a launcher for quick app access, and excellent NVIDIA driver support out of the box.

Pop!_OS desktop screenshot

Pop!_OS is particularly popular among:

  • Gamers (thanks to Steam and Proton integration)
  • Developers (includes many development tools)
  • Users with NVIDIA graphics cards

Website: https://pop.system76.com/

Zorin OS

Zorin OS is a beginner-focused distribution designed to make the move from Windows or macOS easier. It includes polished desktop layouts, strong hardware compatibility, and a simple settings experience for new users.

Zorin OS is a strong option when you want:

  • A familiar desktop layout with minimal setup
  • Stable Ubuntu-based package compatibility
  • Good out-of-the-box support for everyday desktop tasks
Zorin OS desktop screenshot

Website: https://zorin.com/os/

elementary OS

elementary OS is a design-focused distribution with a macOS-like interface called Pantheon. It emphasizes simplicity, consistency, and a curated app experience through its AppCenter.

elementary OS desktop screenshot

elementary OS is a good fit when you want:

  • A clean, visually polished desktop out of the box
  • A curated app store with native applications
  • A macOS-like workflow on Linux

Website: https://elementary.io/

Lightweight Linux Distros

These distributions are designed for older hardware or users who prefer a minimal, fast system.

Xubuntu

Xubuntu combines Ubuntu’s reliability with the Xfce desktop environment, offering a good balance between performance and features. It is lighter than standard Ubuntu while remaining full-featured for daily desktop use.

Xubuntu is a practical choice when you need:

  • Better performance on older hardware
  • A traditional desktop workflow
  • Ubuntu repositories and long-term support options
Xubuntu desktop screenshot

Website: https://xubuntu.org/

Lubuntu

Lubuntu uses the LXQt desktop environment, making it one of the lightest Ubuntu-based distributions available. It is designed for very old or resource-constrained hardware where even Xfce feels heavy.

Lubuntu desktop screenshot

Lubuntu works well when you need:

  • Minimal memory and CPU usage
  • A functional desktop on very old hardware
  • Access to Ubuntu repositories and LTS support

Website: https://lubuntu.me/

Linux Distros for Advanced Users

These distributions offer more control and customization but require technical knowledge to set up and maintain.

Arch Linux

Arch Linux follows a “do-it-yourself” philosophy, providing a minimal base system that users build according to their needs. It uses a rolling release model, meaning you always have the latest software without major version upgrades.

Key features:

  • Pacman package manager with access to vast repositories
  • Arch User Repository (AUR) for community packages
  • Excellent documentation in the Arch Wiki
  • Complete control over every aspect of the system
Arch Linux desktop screenshot
Tip
Arch Linux requires manual installation via the command line. If you want the Arch experience with an easier setup, consider Manjaro or EndeavourOS.

Website: https://archlinux.org/

EndeavourOS

EndeavourOS is an Arch-based distribution that keeps the Arch philosophy while simplifying installation and initial setup. It is popular among users who want a near-Arch experience without doing a fully manual install.

EndeavourOS gives you:

  • Rolling release updates
  • Access to Arch repositories and AUR packages
  • A cleaner onboarding path than a base Arch install
EndeavourOS desktop screenshot

Website: https://endeavouros.com/

Fedora

Fedora is a cutting-edge distribution sponsored by Red Hat. It showcases the latest open-source technologies while maintaining stability, making it popular among developers and system administrators.

Fedora desktop screenshot

Fedora editions include:

  • Fedora Workstation — Desktop with GNOME
  • Fedora Server — For server deployments
  • Fedora Silverblue — Immutable desktop OS
  • Fedora Spins — Alternative desktops (KDE, Xfce, etc.)

Many Red Hat technologies debut in Fedora before reaching RHEL, making it ideal for learning enterprise Linux.

Website: https://fedoraproject.org/

openSUSE

openSUSE is a community-driven distribution known for its stability and powerful administration tools. It offers two main variants:

  • openSUSE Leap — Regular releases based on SUSE Linux Enterprise
  • openSUSE Tumbleweed — Rolling release with the latest packages

The YaST (Yet another Setup Tool) configuration utility makes system administration straightforward, handling everything from software installation to network configuration.

openSUSE desktop screenshot

Website: https://www.opensuse.org/

Linux Distros for Gaming

Gaming-focused distributions prioritize current graphics stacks, controller support, and compatibility with modern Steam and Proton workflows.

Bazzite

Bazzite is an immutable Fedora-based desktop optimized for gaming and handheld devices. It ships with gaming-focused defaults and integrates well with Steam, Proton, and modern GPU drivers.

Bazzite is ideal when you want:

  • A Steam-first gaming setup
  • Reliable rollback and update behavior from an immutable base
  • A distro tuned for gaming PCs and handheld hardware
Bazzite desktop screenshot

Website: https://bazzite.gg/

Linux Distros for Servers

These distributions are optimized for stability, security, and long-term support in server environments.

Debian

Debian is one of the oldest and most influential Linux distributions. Known for its rock-solid stability and rigorous testing process, it serves as the foundation for Ubuntu, Linux Mint, Kali Linux, and many other distributions.

Debian desktop screenshot

Debian offers three release channels:

  • Stable — Thoroughly tested, ideal for production servers
  • Testing — Upcoming stable release with newer packages
  • Unstable (Sid) — Rolling release with the latest software

With over 59,000 packages in its repositories, Debian supports more hardware architectures than any other Linux distribution.

Website: https://www.debian.org/

Red Hat Enterprise Linux (RHEL)

RHEL is the industry standard for enterprise Linux deployments. It offers:

  • 10-year support lifecycle
  • Certified hardware and software compatibility
  • Red Hat Insights for predictive analytics
  • Professional support from Red Hat

RHEL runs on multiple architectures including x86_64, ARM64, IBM Power, and IBM Z.

Website: https://www.redhat.com/

Rocky Linux

After CentOS shifted to CentOS Stream, Rocky Linux emerged as a community-driven RHEL-compatible distribution. Founded by one of the original CentOS creators, it provides 1:1 binary compatibility with RHEL.

Rocky Linux desktop screenshot

Rocky Linux is ideal for:

  • Organizations previously using CentOS
  • Production servers requiring stability
  • Anyone needing RHEL compatibility without the cost

Website: https://rockylinux.org/

Ubuntu Server

Ubuntu Server is widely used for cloud deployments and containerized workloads. It powers a significant portion of public cloud instances on AWS, Google Cloud, and Azure.

Features include:

  • Regular and LTS releases
  • Excellent container and Kubernetes support
  • Ubuntu Pro for extended security maintenance
  • Snap packages for easy application deployment

Website: https://ubuntu.com/server

SUSE Linux Enterprise Server (SLES)

SUSE Linux Enterprise Server is designed for mission-critical workloads. It excels in:

  • SAP HANA deployments
  • High-performance computing
  • Mainframe environments
  • Edge computing

SLES offers a common codebase across different environments, simplifying workload migration.

Website: https://www.suse.com/products/server/

Linux Distros for Security and Privacy

These distributions focus on security testing, anonymity, and privacy protection.

Kali Linux

Kali Linux is the industry-standard platform for penetration testing and security research. Maintained by Offensive Security, it includes hundreds of security tools preinstalled.

Common use cases:

  • Penetration testing
  • Security auditing
  • Digital forensics
  • Reverse engineering
Kali Linux desktop screenshot
Warning
Kali Linux is designed for security professionals. It should not be used as a daily driver operating system.

Website: https://www.kali.org/

Tails

Tails (The Amnesic Incognito Live System) is a portable operating system designed for privacy and anonymity. It runs from a USB drive and routes all traffic through the Tor network.

Key features:

  • Leaves no trace on the host computer
  • All connections go through Tor
  • Built-in encryption tools
  • Amnesic by design (forgets everything on shutdown)
Tails desktop screenshot

Website: https://tails.net/

Qubes OS

Qubes OS takes a unique approach to security by isolating different activities in separate virtual machines called “qubes.” If one qube is compromised, others remain protected.

The Xen hypervisor runs directly on hardware, providing strong isolation between:

  • Work applications
  • Personal browsing
  • Untrusted software
  • Sensitive data

Website: https://www.qubes-os.org/

Parrot Security OS

Parrot Security is a Debian-based distribution for security testing, development, and privacy. It is lighter than Kali Linux and can serve as a daily driver.

Parrot offers several editions:

  • Security Edition — Full security toolkit
  • Home Edition — Privacy-focused daily use
  • Cloud Edition — For cloud deployments

Website: https://parrotsec.org/

Getting Started

Once you have chosen a distro, the next steps are:

  1. Download the ISO from the official website
  2. Create a bootable USB drive — See our guide on creating a bootable Linux USB
  3. Try it live before installing (most distros support this)
  4. Install following the distro’s installation wizard

Quick Comparison

Distro Best For Desktop Package Manager Based On
Ubuntu Beginners GNOME APT Debian
Linux Mint Windows users Cinnamon APT Ubuntu
Zorin OS New Linux users GNOME (Zorin desktop) APT Ubuntu
elementary OS macOS-like experience Pantheon APT Ubuntu
Fedora Developers GNOME DNF Independent
Debian Stability/Servers GNOME APT Independent
Arch Linux Advanced users Any Pacman Independent
EndeavourOS Arch with easier setup Xfce (default) Pacman Arch Linux
Pop!_OS Gaming/Developers COSMIC APT Ubuntu
Bazzite Gaming KDE/GNOME variants RPM-OSTree Fedora
Rocky Linux Enterprise servers None DNF RHEL
Xubuntu Older hardware Xfce APT Ubuntu
Lubuntu Very old hardware LXQt APT Ubuntu
Kali Linux Security testing Xfce APT Debian

FAQ

Which Linux distro is best for beginners?
Ubuntu, Linux Mint, and Zorin OS are the best choices for beginners. Ubuntu has the largest community and most documentation, while Linux Mint and Zorin OS provide a familiar desktop experience.

Can I try a Linux distro without installing it?
Yes. Most distributions support “live booting” from a USB drive, allowing you to test the system without making any changes to your computer.

Is Linux free?
Most Linux distributions are completely free to download and use. Some enterprise distros like RHEL offer paid support subscriptions.

Can I run Windows software on Linux?
Many Windows applications run on Linux through Wine or Proton (for games via Steam). Native alternatives like LibreOffice, GIMP, and Firefox are also available.

What is a rolling release distro?
A rolling release distro (like Arch Linux or openSUSE Tumbleweed) delivers continuous updates instead of major version upgrades. You always have the latest software, but updates require more attention.

Conclusion

The best Linux distribution depends entirely on your needs and experience level. If you are new to Linux, start with Ubuntu, Linux Mint, or Zorin OS. If you want full control over your system, try Arch Linux, EndeavourOS, or Fedora. For gaming, Pop!_OS and Bazzite are strong options. For servers, Debian, Rocky Linux, Ubuntu Server, and RHEL are all solid choices. For security testing, Kali Linux and Parrot Security are the industry standards.

Most distributions are free to download and try. Create a bootable USB, test a few options, and find the one that fits your workflow.

If you have any questions, feel free to leave a comment below.

SCP Cheatsheet

Basic Syntax

Use this general form for scp commands.

Command Description
scp SOURCE DEST General scp syntax
scp file.txt user@host:/path/ Copy local file to remote
scp user@host:/path/file.txt . Copy remote file to current directory
scp user@host:/path/file.txt /local/path/ Copy remote file to local directory

Upload Files

Copy local files to a remote host.

Command Description
scp file.txt user@host:/tmp/ Upload one file
scp file1 file2 user@host:/tmp/ Upload multiple files
scp *.log user@host:/var/log/archive/ Upload matching files
scp -p file.txt user@host:/tmp/ Preserve modification times and mode

Download Files

Copy files from a remote host to your local system.

Command Description
scp user@host:/tmp/file.txt . Download to current directory
scp user@host:/tmp/file.txt ~/Downloads/ Download to specific directory
scp user@host:'/var/log/*.log' . Download remote wildcard (quoted)
scp user@host:/tmp/file.txt ./new-name.txt Download and rename locally

Copy Directories

Use -r for recursive directory transfers.

Command Description
scp -r dir/ user@host:/tmp/ Upload directory recursively
scp -r user@host:/var/www/ ./backup/ Download directory recursively
scp -r dir1 dir2 user@host:/tmp/ Upload multiple directories
scp -rp project/ user@host:/srv/ Recursive copy and preserve attributes

Ports, Keys, and Identity

Connect with custom SSH settings.

Command Description
scp -P 2222 file.txt user@host:/tmp/ Use custom SSH port
scp -i ~/.ssh/id_ed25519 file.txt user@host:/tmp/ Use specific private key
scp -o IdentityFile=~/.ssh/id_ed25519 file.txt user@host:/tmp/ Set key with -o option
scp -o StrictHostKeyChecking=yes file.txt user@host:/tmp/ Enforce host key verification

Performance and Reliability

Tune speed, verbosity, and resilience.

Command Description
scp -C large-file.iso user@host:/tmp/ Enable compression
scp -l 8000 file.txt user@host:/tmp/ Limit bandwidth (Kbit/s)
scp -v file.txt user@host:/tmp/ Verbose output for debugging
scp -q file.txt user@host:/tmp/ Quiet mode
scp -o ConnectTimeout=10 file.txt user@host:/tmp/ Set connection timeout

Remote to Remote Copy

Transfer files between two remote hosts.

Command Description
scp user1@host1:/path/file user2@host2:/path/ Copy between remote hosts
scp -3 user1@host1:/path/file user2@host2:/path/ Route transfer through local host
scp -P 2222 user1@host1:/path/file user2@host2:/path/ Use custom port (applies to both hosts)

Common Patterns

Frequently used command combinations.

Command Description
scp -r ./site user@host:/var/www/ Deploy static site files
scp -i ~/.ssh/id_ed25519 -P 2222 backup.sql user@host:/tmp/ Upload with key and custom port
scp user@host:/etc/nginx/nginx.conf ./ Pull config for review
scp -rp ./configs user@host:/etc/myapp/ Copy configs and keep metadata

Troubleshooting

Check Command
Permission denied Verify user has write access to the destination path
Host key verification failed ssh-keygen -R hostname to remove old key, then retry
Connection refused on custom port scp -P PORT file user@host:/path/ (uppercase -P)
Transfer stalls or times out scp -o ConnectTimeout=10 -o ServerAliveInterval=15 file user@host:/path/
Not a regular file error Add -r for directories: scp -r dir/ user@host:/path/
Protocol error on OpenSSH 9.0+ scp -O file user@host:/path/ to use legacy SCP protocol
Debug connection issues scp -v file user@host:/path/ for verbose SSH output

Related Guides

Use these articles for detailed file transfer and SSH workflows.

Guide Description
How to Use SCP Command to Securely Transfer Files Full scp guide with practical examples
SSH Command in Linux SSH options, authentication, and connection examples
How to Use Linux SFTP Command to Transfer Files Interactive secure file transfer over SSH
How to Use Rsync for Local and Remote Data Transfer Incremental sync and directory transfer

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,不仅是选择了一个数据库,更是选择了一种“做减法”的架构哲学。

❌
❌