普通视图

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

亿元Cocos小游戏实战合集2.0

2026年4月10日 11:48

历史截图,实际以合集内容为准

引言

哈喽大家好,我是亿元程序员,一位有着8年游戏行业经验的主程。

依旧笔者Slogan

在游戏开发中,希望能给到小伙伴们帮助, 也希望通过小伙伴们能帮助到大家。

相信一直有关注笔者文章的小伙伴都知道,**《亿元Cocos小游戏实战合集1.0》**已经顺利完结:

  • 历时100天,10个热门游戏完整拆解。
  • 收获12.7k阅读,无数小伙伴的催更和好评。
  • "画线救狗"到"绳子纹理",一路有你。

1.0合集内容

随着1.0的完结,我一直在思考:2.0应该怎么做才能更受大家欢迎?

答案很简单:精和卷,数量虽然从106,但这次内容更精品,更"卷"了。

因此《亿元Cocos小游戏实战合集2.0》 预热开始。

合集2.0配套源码可在文末获取,小伙伴们自行前往。

1. 为什么只有6个了?

因为从"堆量"变成"做精"。

1.0的时候,很多小伙伴反馈:"源码给了,但看懂还是需要基础。"

而且内容太多容易导致时间跨度太久,失去时效性。

2.0的每一个实战,我都会:

维度 1.0 2.0
数量 10个 6个
深度 核心玩法实战 更深
注释 关键代码注释 更明了
扩展 配套源码 更便捷

所以,6个 ≠ 缩水,6个 = 精品。

2.合集内容

合集内容依旧是时下热门游戏的手把手拆解实战与技术剖析。

目前已经在计划中的游戏包括:

1.3D箭头游戏

能看出来像什么吗

作为2.0首发,其亮点如下:

  • 3D游戏开发
  • 3D游戏Shader
  • 3D游戏合批
  • 热门箭头游戏素材
  • 3D游戏关卡编辑器

2.毛线排序

我第一个支持

这是小伙伴从1.0一直心心念念到2.0的毛线玩法,决定在此合集给小伙伴们安排上,其亮点如下:

  • 热门毛线素材
  • 热门像素素材
  • 1.0的绳子纹理应用
  • 热门排序解谜玩法

3.其余的4个

待定,但肯定是热门,选品标准如下:

  • 近期热门游戏
  • 游戏机制有技术点可挖
  • 方便蹭流量的
  • 有想让我实战的游戏?(评论区见)

3.适合人群?

图片由AI生成

  • 1.0老同学,想继续跟学的
  • 想要入门或更进一步的小伙伴
  • 想热门游戏快速二开的开发者
  • 想看笔者继续"整活"的吃瓜群众

总的来说,2.0比1.0更"甜",就算有苦的,我也会熬成糖喂给你!

4.其他说明

合集2.0体验地址(加载需要点时间):

https://yiyuangamecases2.pages.dev/

为了宠粉(其实是还没写好),合集2.0预热骨扣三天,结束后恢复正常,并且会随着合集的更新继续上调,早入手不亏。

与此同时1.0也成了"历史经典款",将会同步上调,还在观望1.0的小伙伴,快上车。

入手后依旧可联系笔者加入专属群组,手把手解答相关疑问,探讨更好更优的实现,后续更新源码也通过群组发放,感谢大家的支持!

结语

既然都看到这里了,那你一定是真爱粉了!

合集2.0配套源码可通过阅读原文付费获取,链接失效请移步评论区。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

前端并发治理:从 Token 刷新聊起,一个 Promise 就够了

2026年4月10日 11:31

前端没有多线程,按理说不该有并发问题。但只要你写过稍微复杂一点的项目,就一定踩过这些坑:用户连点按钮提交了两次订单、搜索框的旧结果覆盖了新结果、五个请求同时 401 触发了五次 Token 刷新……

这些问题看着各不相同,但背后其实是同一件事——多个异步流程在抢同一个资源。而解决它们的核心思路,往往只需要一个 Promise。

本文从最常见的 Token 刷新场景出发,一步步拆解前端并发问题的本质和通用解法。

前端鉴权那些事

前端处理登录态,方案其实挺多的,不同项目的选择差异很大。

最传统的是 Cookie + Session:登录后服务端种一个 Cookie,之后浏览器每次请求自动带上,前端几乎不用操心。很多项目至今还在用,简单可靠。

前后端分离流行之后,JWT Token 成了主流:后端返回一个 Token,前端存在 localStorage 里,请求时塞进 Header。至于 Token 过期怎么办,不同团队的处理方式五花八门——

最简单的是 401 直接跳登录页,干脆利落,很多内部系统就是这么干的,够用了。

稍微讲究一点的会做滑动续期:后端在每次请求时检查 Token 是否快过期,快过期就在响应头里塞一个新 Token,前端替换掉旧的,类似 Session 的自动续期。还有一种是前端自己算过期时间,快到期时主动刷新,不等 401 再处理。

再往上就是双 Token 机制:一个短期的 access token 用于日常请求(比如 15 分钟过期),一个长期的 refresh token 用于续期(比如 7 天过期)。access token 过期时,前端用 refresh token 静默换一个新的,用户无感知。

说实话,双 Token 是不是"最佳实践",社区一直有争论——有人觉得在自家系统里是过度设计,滑动续期就够了;也有人觉得职责分离确实更安全。这个争论不是本文的重点,但双 Token 的前端实现确实是最能体现并发问题的场景——因为它涉及"Token 过期后静默刷新并重发请求",而这个过程很容易在并发时出 bug。

所以我们就用它作为切入点。双 Token 的前端实现几乎形成了一个固定范式——请求前统一注入 Token,响应后统一拦截刷新

请求发出前,从存储中取出 access token,塞进请求头:

axios.interceptors.request.use(config => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

收到 401 响应时,不直接报错,而是悄悄用 refresh token 换一个新的 access token,然后把刚才失败的请求重新发一遍,用户甚至感知不到:

axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      const newToken = await refreshToken();
      originalRequest.headers.Authorization = `Bearer ${newToken}`;
      return axios(originalRequest);
    }
    return Promise.reject(error);
  }
);

如果 refresh token 也过期了呢?那就退化回最简单的方案——清除登录态,跳回登录页。双 Token 机制不是消灭了"跳登录",只是把它推迟到了最后一刻:

async function refreshToken() {
  try {
    const { data } = await axios.post('/auth/refresh', {
      refresh_token: localStorage.getItem('refresh_token')
    });
    localStorage.setItem('access_token', data.access_token);
    return data.access_token;
  } catch {
    localStorage.clear();
    window.location.href = '/login';
    return Promise.reject();
  }
}

打个比方:请求拦截器负责"带上门禁卡",响应拦截器负责"门禁卡过期时自动换卡再刷一次",换卡也失败就"回前台重新办卡"。

到这里一切看起来很完美。但有一个问题被我们忽略了——如果页面上同时有 5 个请求,它们几乎在同一瞬间都收到了 401,会发生什么?

答案是:5 个请求各自触发一次 refreshToken(),连发 5 次刷新请求。

这显然不对。

并发难题:5 个 401 只该刷新一次

这是前端 Token 鉴权最经典的并发问题。传统方案是维护一个 isRefreshing 标志位加一个等待队列:第一个请求负责刷新,后续请求排队等结果。这种方案能用,但代码比较啰嗦。

其实有一个更简洁的思路:不用队列,直接缓存那个 refresh 的 Promise。 多个请求发现 Token 过期时,如果已经有一个 refresh 在进行中,就直接 await 同一个 Promise——大家等的是同一件事,拿到的是同一个结果:

let refreshPromise = null;

function getNewToken() {
  if (refreshPromise) return refreshPromise;

  refreshPromise = axios
    .post('/auth/refresh', {
      refresh_token: localStorage.getItem('refresh_token'),
    })
    .then(({ data }) => {
      localStorage.setItem('access_token', data.access_token);
      return data.access_token;
    })
    .catch(err => {
      localStorage.clear();
      window.location.href = '/login';
      return Promise.reject(err);
    })
    .finally(() => {
      refreshPromise = null;
    });

  return refreshPromise;
}

整个逻辑就靠一个变量 refreshPromise:有值说明刷新正在进行,所有人直接 await 它;没值就发起刷新并把 Promise 存起来。finally 里清空,这样下一轮过期时又能重新触发。

这个模式就叫 Promise Cache 吧。

等一下,标志位不就够了吗?

看到这里你可能会想:搞什么 Promise Cache,我用一个布尔标志位挡住重复调用不就行了?

let isRefreshing = false;

async function refreshToken() {
  if (isRefreshing) return;
  isRefreshing = true;
  try {
    const { data } = await axios.post('/auth/refresh');
    localStorage.setItem('access_token', data.access_token);
  } finally {
    isRefreshing = false;
  }
}

对于某些场景确实够了——比如埋点上报、按钮防连点,你只需要"别重复执行",不关心结果。但 Token 刷新不行。看看会发生什么:

请求 A 收到 401 → 发起 refresh,isRefreshing = true
请求 B 收到 401 → 发现 isRefreshing → return → 拿到 undefined → 没有新 token → 重发失败
请求 A 的 refresh 成功了 → 但 B 已经错过了

标志位把 B "挡回去"了,但 B 还需要结果啊。Promise Cache 不一样,B 不是被拒绝,而是"挂在同一个 Promise 上等":

请求 A 收到 401 → 发起 refresh,缓存 Promise
请求 B 收到 401 → await 同一个 Promise → 等着
refresh 成功 → AB 同时拿到新 token → 各自重发

所以判断标准很简单:调用者只需要"别重复执行"→ 标志位就够。调用者还需要"等到结果再继续"→ 必须用 Promise Cache。打个比方,前者是"门卫拦人",后者是"拼车到终点"。

举一反三:前端并发问题的两大类

Token 刷新只是冰山一角。一旦你理解了 Promise Cache 的本质,就会发现前端到处都有类似的并发场景。它们大致分两类:

第一类:多次触发,只该执行一次

这正是 Promise Cache 的主场。除了 Token 刷新,还有——

多个组件同时请求同一个接口。 比如页面上三个组件都需要用户信息,几乎同时调 GET /user,没必要发三次:

const pending = new Map();

function dedupRequest(key, requestFn) {
  if (pending.has(key)) return pending.get(key);
  const p = requestFn().finally(() => pending.delete(key));
  pending.set(key, p);
  return p;
}

dedupRequest('user-info', () => axios.get('/user'));

按钮防重复提交。 用户手快连点了三次"下单":

let submitPromise = null;

async function handleSubmit(data) {
  if (submitPromise) return submitPromise;
  submitPromise = axios.post('/order', data).finally(() => {
    submitPromise = null;
  });
  return submitPromise;
}

模式完全一样:有在飞的 Promise 就复用,没有就新建一个。

第二类:多次触发,只保留最后一次

搜索联想是最典型的例子。用户快速输入 a → ab → abc,三个请求飞出去,但 a 的请求可能最后才返回,把 abc 的正确结果覆盖掉。

这里要做的不是合并,而是丢弃过期的结果。最简单的方案是用一个自增 ID:

let currentRequestId = 0;

async function search(keyword) {
  const id = ++currentRequestId;
  const res = await axios.get('/search', { params: { q: keyword } });
  if (id !== currentRequestId) return; // 已经过时了,丢掉
  setResults(res.data);
}

更彻底的做法是用 AbortController 直接取消上一次请求,连响应都不用判断:

let controller = null;

async function search(keyword) {
  controller?.abort();
  controller = new AbortController();
  const res = await axios.get('/search', {
    params: { q: keyword },
    signal: controller.signal,
  });
  setResults(res.data);
}

你可能会问:用时间戳代替自增 ID 行不行?能用,但有坑。浏览器里 Date.now() 精度通常只有 1ms,有些浏览器出于安全考虑(防 Spectre 攻击)甚至故意降到 5ms。用户快速输入时,两次调用完全可能拿到同一个时间戳,竞态又回来了。自增 ID 就没这个问题,每次 ++ 天然唯一、严格递增,不依赖任何平台特性。至于溢出?Number.MAX_SAFE_INTEGER 约 9 千万亿,每秒自增 1000 次也要 2.85 亿年才会用完,页面一刷新还归零。

异步单例:当 Promise Cache 遇上设计模式

聊完了接口层的并发,再看一个更"架构"的场景——SDK 初始化。

单例模式大家都熟悉:

class SDK {
  static instance = null;
  static getInstance() {
    if (!this.instance) this.instance = new SDK();
    return this.instance;
  }
}

同步实例化时没问题。但前端 SDK 的初始化往往是异步的——加载远程脚本、拉取配置、建立 WebSocket 连接。这时候单例就有一个微妙的 bug:

模块 A 调用 getInstance() → instance 为 null → new SDK() → 开始异步 init()...
模块 B 调用 getInstance() → instance 已经存在!→ 直接返回 → 拿到一个还没初始化完的实例 → 💥

问题出在哪?单例只保证了"只 new 一次",但没保证"等初始化完再给你"。这恰好是 Promise Cache 能解决的:

class SDK {
  static initPromise = null;
  static getInstance() {
    if (!this.initPromise) {
      const sdk = new SDK();
      this.initPromise = sdk.init().then(() => sdk);
    }
    return this.initPromise;
  }
}

const sdk1 = await SDK.getInstance(); // 触发初始化
const sdk2 = await SDK.getInstance(); // 挂在同一个 Promise 上等
// sdk1 === sdk2,且都是初始化完成的

单例保证"只创建一个实例",Promise Cache 保证"只执行一次异步过程,且所有人都能等到结果"。 可以说,Promise Cache 就是异步世界的单例模式。

但这样有个代价:async 传染

上面的方案解决了并发问题,却带来了一个新的烦恼——初始化只需要等一次,但之后每次调用 getInstance() 都要写 await,即使 Promise 早就 resolved 了。虽然性能上没问题(只是一个 microtask),但 async 像病毒一样"传染",逼着所有调用方都变成异步函数。

一种改进是两层缓存——初始化阶段缓存 Promise,完成后缓存实例:

class SDK {
  static instance = null;
  static initPromise = null;

  static getInstance() {
    if (this.instance) return this.instance;           // 已完成,同步返回
    if (this.initPromise) return this.initPromise;     // 进行中,返回 Promise
    this.initPromise = new SDK().init().then(sdk => {
      this.instance = sdk;
      this.initPromise = null;
      return sdk;
    });
    return this.initPromise;
  }
}

但这带来了新的心智负担:getInstance() 有时返回实例,有时返回 Promise,调用方需要知道当前是哪个阶段。

更干净的做法是把初始化和获取拆成两个方法,各司其职:

class SDK {
  static instance = null;
  static initPromise = null;

  static init() {
    if (this.initPromise) return this.initPromise;
    this.initPromise = new SDK().setup().then(sdk => {
      this.instance = sdk;
      return sdk;
    });
    return this.initPromise;
  }

  static getInstance() {
    if (!this.instance) throw new Error('SDK 未初始化,请先调用 SDK.init()');
    return this.instance; // 永远同步
  }
}

使用起来职责清晰:

// 应用入口,只调一次
await SDK.init();

// 之后所有地方,同步获取
const sdk = SDK.getInstance();
sdk.doSomething();

这也是大部分主流 SDK 的实际做法——在应用启动时 await 一次初始化,之后全同步访问。

当然,这意味着调用方需要自己保证时序——getInstance() 必须在 init() 完成之后才能调。实践中一般把 init() 卡在应用挂载之前来解决这个问题:

async function bootstrap() {
  await SDK.init();
  app.mount('#root'); // SDK 就绪后才启动应用
}
bootstrap();

这也是为什么 Vue 的 app.use()、各种插件的 install() 都设计在 mount() 之前——用启动流程的顺序来隐式保证时序。

归根结底是一个取舍:Promise Cache 让框架替你管时序,调用方无脑 await 就行,但 async 会传染;init/getInstance 分离给了你同步访问的清爽,但得自己控制好初始化入口。SDK 是全局基础设施、入口明确的,分离方案更干净;初始化时机不确定、调用方散落各处的,Promise Cache 更安全。

总结

前端的"并发问题"大多不是真正的多线程竞争,而是多个异步流程在抢同一个资源。折腾到最后,核心解法就两个:

而 Promise Cache 的本质,就是异步世界的单例模式。一个变量,一个 if 判断,一个 finally 清理——三行逻辑,解决一大类问题。

下次再遇到"多个地方同时调、但只该执行一次"的需求,别急着加锁、加队列、加标志位。先想想:能不能缓存那个 Promise?

Emscripten 从 JavaScript 调用 C/C++

作者 山河木马
2026年4月10日 11:23

Emscripten是一个开源编译器工具链,主要用于将C/C++代码编译为WebAssembly(Wasm)

因为最后生成的WASM是在前端使用和运行,所以少不了c/c++和js代码之间的接口调用。

Emscripten 提供了三种从 C/C++ 调用 JavaScript 的主要方法:

  1. 使用ccall直接调用
  2. 使用cwrap包装成JS函数
  3. 通过Module对象以下划线_开头的函数名直接调用(这个最方便使用)

1. 编写供 JS 调用的 C++ 代码(基本语法和宏)

#include <emscripten.h> // 必须包含的头文件

// 使用 extern "C" 避免 C++ 名称修饰
extern "C" {

    // 使用 EMSCRIPTEN_KEEPALIVE 宏确保函数不会被优化删除
    EMSCRIPTEN_KEEPALIVE
    int add(int a, int b) {
        return a + b;
    }

} // extern "C"

2. JS 调用 c++ 代码

2.1 ccall

// 使用 ccall(函数名,返回类型,[参数类型,...],[参数值,...])
var result = Module.ccall('add', 'number', ['number', 'number'], [10, 20]); 
console.log(result); // 输出 30

2.2 cwrap

// 使用 cwrap(函数名,返回类型,[参数类型,...])
var addFunction = Module.cwrap('add', 'number', ['number', 'number']);
var result = addFunction(10, 20);
console.log(result); // 输出 30

优点:自动化程度高,C函数参数类型为char*时,Emscripten会自动分配和释放临时内存

2.3 通过Module对象以下划线_开头的函数名直接调用

var result = Module._add(10, 20);
console.log(result); // 输出 30

减少调用开销,性能更好,需手动管理内存

3. 如何处理复杂数据类型

对于字符串、数组等复杂数据类型,你需要通过Emscripten的堆(Heap)来进行内存操作。

3.1 字符串传递:使用Emscripten提供的字符串转换函数

C++ 返回字符串

// C++ 返回字符串
EMSCRIPTEN_KEEPALIVE
const char* get_greeting() {
    return "Hello from C++!";
}
// JavaScript 端调用并转换字符串
var ptr = Module._get_greeting();
var str = Module.UTF8ToString(ptr);
console.log(str); // 输出 "Hello from C++!"

JS 传递字符串

#include <emscripten.h>
#include <string.h>

EMSCRIPTEN_KEEPALIVE
int get_string_length(const char* str) {
  return strlen(str);
}
// 为字符串分配内存,并将字符串复制进去
const str = 'Hello Direct Call';
const buffer = Module._malloc(str.length + 1); // 额外字节存放字符串结束符 '\0'
Module.stringToUTF8(str, buffer, str.length + 1); // 将JavaScript字符串转换为UTF8编码存入内存

// 直接调用C函数
const length = Module._get_string_length(buffer);
console.log('String length:', length);

// 务必释放内存!
Module._free(buffer);

编译命令中要加入这段

-sEXPORTED_RUNTIME_METHODS=['UTF8ToString','stringToUTF8']

内存管理是关键

若手动分配内存(如使用Module._mallocModule.stringToUTF8),务必在最后使用Module._free释放,防止内存泄漏。若使用ccall/cwrap并指定'string'参数类型,Emscripten会自动管理临时分配的内存。

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

作者 SmalBox
2026年4月10日 10:34

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

Add节点核心功能与数学原理

Add节点是ShaderGraph中数学运算的基础组件,其功能遵循向量加法规则。当输入为标量时,输出为两个数值的算术和;当输入为向量时,则按分量逐项相加(如RGBA通道分别相加)。数学表达式为:

Output = InputA + InputB

在图形学应用中,该操作常用于:

  • 颜色混合:叠加纹理颜色与基础色,实现多图层融合效果
  • 参数补偿:为动画参数添加偏移量,实现动态调节
  • 光照计算:累积漫反射与高光分量,增强视觉层次感

此外,Add节点支持多通道数据并行处理,例如在法线贴图与基础法线叠加时,可逐通道计算法向量,从而提升材质细节的表现力。

Add节点在URP管线中的特性

在URP(通用渲染管线)环境下,Add节点具有以下特性:

  • 维度自适应:支持Vector2/3/4、Color等多种数据类型输入,自动适配不同精度的计算需求
  • 性能优化:底层实现为HLSL的add指令,计算效率高,适用于移动端与高性能平台
  • 与光照节点协同:常与LightDirection节点配合实现动态光照效果,例如在角色高光区域叠加动态光源影响
  • 混合模式扩展:通过嵌套使用可实现类似Additive混合的视觉效果,例如粒子系统中的发光叠加

自URP 14.0版本起,Add节点进一步支持HDR颜色输入,允许在后期处理中实现超范围亮度叠加,为高动态范围渲染提供更多可能性。

Add节点基础应用场景

颜色混合实现

通过将两个Texture2D采样节点连接至Add节点,可实现基础颜色叠加:

  • BaseTexture → InputA
  • OverlayTexture → InputB
  • Output → FinalColor 这种组合常用于创建以下效果:
  • 磨损金属材质(基础色+划痕纹理):通过叠加锈迹与金属底色,模拟真实磨损效果
  • 动态天气效果(云层+雨滴透明度):在天空盒中叠加雨滴透明度,实现动态降水视觉
  • 发光效果(基础色+高光通道):为UI元素或特效添加自发光叠加,增强视觉吸引力

参数补偿控制

在动画系统中,Add节点可用于:

  • 为顶点位移添加随机噪声:通过叠加Perlin噪声,实现自然风动效果
  • 控制动画速度的微调:在时间参数上叠加偏移量,实现变速动画
  • 实现多参数联动的光照强度调节:例如根据角色距离动态增强环境光

光照计算增强

配合URP光照节点,Add节点能实现:

  • 漫反射与高光的强度叠加:在PBR材质中累积直接光照与间接光照贡献
  • 多光源照明的累积计算:通过逐光源叠加,实现复杂场景的光照融合
  • 环境光遮蔽效果的增强:在AO通道上叠加额外遮蔽强度,提升场景深度感

Add节点进阶应用技巧

混合模式扩展

通过Add节点与Multiply/Lerp节点组合,可模拟专业混合模式:

  • Additive混合:直接使用Add节点,适用于粒子系统与发光效果
  • Screen混合:Add节点配合OneMinus节点,实现颜色减淡效果
  • Overlay混合:Add节点嵌套Multiply节点,创建高对比度混合

动态参数控制

利用Add节点实现:

  • 随时间变化的颜色偏移:通过Time节点驱动颜色通道叠加,实现彩虹渐变效果
  • 基于距离的强度衰减:在雾效计算中叠加距离参数,实现动态浓度变化
  • 交互响应的参数补偿:根据玩家输入叠加位移量,实现实时交互反馈

性能优化策略

  • 避免在顶点着色器中过度使用Add运算:优先在片段着色器执行混合操作
  • 对固定参数使用常量节点替代:减少运行时计算开销
  • 在URP渲染设置中启用Shader优化选项:自动简化冗余Add操作

Add节点常见问题与解决方案

颜色溢出问题

当叠加颜色超过[0,1]范围时:

  • 使用Saturate节点钳制输出:确保颜色值在合法范围内
  • 调整混合透明度参数:通过Alpha通道控制叠加强度
  • 采用Remap节点重新映射值域:将溢出颜色映射到可视范围

性能瓶颈排查

  • 检查是否在过度绘制区域使用Add节点:通过Frame Debugger识别高频调用区域
  • 分析Shader编译警告中的数学运算复杂度:关注HLSL代码中的add指令数量
  • 使用Frame Debugger查看Add操作执行频率:定位渲染管线中的性能热点

混合效果异常

  • 验证输入纹理的格式是否匹配:确保RGB与Alpha通道数据一致
  • 检查URP材质球混合模式设置:确认Add节点与材质混合模式兼容
  • 确认Add节点后的颜色空间转换:在Gamma与Linear空间下验证效果一致性

Add节点与其他节点的协同应用

与Lerp节点配合

实现平滑过渡效果:

  • BaseValue → InputA
  • AddNode → InputB
  • Lerp参数 → Time节点 典型应用包括角色血条渐变、场景昼夜过渡等需要线性插值的场景。

与Power节点组合

创建指数级增长效果:

  • Add节点输出 → Power节点
  • 指数参数 → 动画曲线 适用于爆炸冲击波、能量聚集等需要非线性强度变化的特效。

在URP光照管线中的应用

  • 与LightColor节点结合实现动态光照:根据光源颜色叠加高光色调
  • 配合LightDirection节点计算复合光照:累积多方向光源贡献
  • 在阴影计算中补偿环境光影响:通过叠加环境光强度,减轻阴影死黑

Add节点实战案例解析

案例1:动态水波纹效果

  1. 创建Time节点驱动波纹频率:通过正弦波模拟自然波动
  2. 使用Noise节点生成波纹图案:叠加多频噪声实现细节丰富度
  3. 通过Add节点叠加基础位移:累积法线偏移与高度偏移
  4. 配合NormalMap节点实现视觉凹凸:在片段着色器中计算光照反射

案例2:多材质混合系统

  1. 使用Lerp节点控制混合区域:根据遮罩纹理决定混合权重
  2. 通过Add节点累积各材质贡献:叠加漫反射、高光与自发光通道
  3. 配合URP的LitShader实现物理正确混合:确保能量守恒与光线反射准确
  4. 使用TextureCoordinate节点控制混合映射:实现基于UV的局部材质融合

案例3:光照增强效果

  1. 获取基础光照强度:通过URP Light Probe采样环境光
  2. 使用Add节点增强高光区域:在Specular通道叠加额外亮度
  3. 配合Fresnel节点实现边缘光:根据视角叠加边缘发光强度
  4. 在URP材质中启用Specular选项:确保高光计算与Add节点协同

Add节点最佳实践建议

  • 参数化设计:将Add操作封装为可复用的子图,提升Shader可维护性
  • 性能监控:使用URP的Shader分析工具检测Add运算开销,优化高频调用
  • 版本兼容:确保Add节点行为在不同URP版本中一致,测试12.0至14.0版本差异
  • 文档规范:为复杂Add操作添加注释说明,标注输入输出数据类型与预期效果
  • 测试覆盖:创建包含Add节点的材质测试用例,验证边界条件与异常情况

Add节点未来发展方向

随着URP的持续演进,Add节点可能:

  • 支持AI驱动的参数自动优化:通过机器学习预测最佳混合参数
  • 集成到URP的实时GI系统中:在全局光照计算中实现更高效的亮度累积
  • 与Compute Shader实现更高效的混合计算:利用GPU并行能力提升大规模叠加性能
  • 提供可视化调试工具链:实时显示Add操作输入输出值,辅助Shader调试

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

Sass与Less全面对比(含语法+场景+选型)

2026年4月10日 10:31

Sass(Syntactically Awesome Style Sheets)和 Less(Leaner Style Sheets)是目前最主流的两款CSS预处理器,二者核心目标一致——扩展CSS的功能,解决原生CSS无变量、无嵌套、无复用性等痛点,让样式开发更高效、代码更易维护。但两者在起源、语法细节、功能特性、生态支持等方面存在显著差异,选择时需结合项目规模、团队习惯和需求场景综合判断。

一、核心差异总览(表格清晰对比)

对比维度 Sass Less
起源与底层实现 2007年诞生,最初基于Ruby实现,目前官方推荐使用Dart Sass(更易维护、性能更优),Node Sass已停更废弃。 2009年诞生,基于JavaScript实现,依赖Node.js环境编译,学习门槛相对较低,可在浏览器端直接解析(不推荐生产环境)。
语法风格 支持两种语法:① SCSS(.scss后缀):兼容原生CSS,使用大括号和分号,目前最常用;② 缩进式(.sass后缀):无大括号和分号,靠缩进区分代码块,格式要求严格。 仅支持一种语法(.less后缀),完全兼容原生CSS,必须使用大括号和分号,写法与原生CSS高度一致,上手更轻松。
变量声明 使用$符号定义变量,支持!default设置默认值(仅在变量未定义时生效),作用域严格,局部变量不影响全局,重定义未加!default的变量会报错。 使用@符号定义变量,采用“懒求值”机制,同名变量后声明会覆盖前声明(无论是否在嵌套块内),局部变量可直接覆盖全局变量,无报错提示。
嵌套与父选择器 支持选择器嵌套,父选择器&解析严格,要求符号与选择器间无多余空格(如&:hover合法),带空格会编译报错,避免隐性错误。 支持选择器嵌套,父选择器&解析宽松,允许省略空格或多余空格(如&.disabled& .disabled均合法),易出现“编译成功但结果异常”的情况。
混合(Mixins) @mixin定义混合宏,@include调用,支持参数、默认值和可变参数,功能灵活,可配合逻辑控制实现复杂复用逻辑。 用类选择器(可加括号,加括号不输出到CSS)定义混合,直接通过类名调用,支持参数和默认值,功能相对基础,无复杂逻辑支持。
继承 @extend实现继承,支持“占位符选择器”(%开头),仅用于继承,不生成冗余CSS,复用效率更高。 @extend实现继承,但不支持占位符选择器,被继承的类会被编译到最终CSS中,易产生冗余代码。
逻辑控制 支持完整的逻辑控制:@if/@else条件判断、@for/@each/@while循环,还可通过@function自定义函数,适合复杂动态样式生成。 逻辑控制能力较弱,仅支持简单的when条件判断和递归循环(需手动终止),无原生自定义函数功能,复杂逻辑需通过混合模拟。
模块化机制 采用现代化模块系统,通过@use(直接使用模块)和@forward(转发模块成员)实现模块化,自动单例加载,支持命名空间和私有成员,彻底避免命名冲突和重复加载。 依赖@import实现模块化,无命名空间和单例加载机制,多次导入同一文件会重复编译,易造成全局污染和代码冗余,无原生私有成员支持。
内置函数 内置函数丰富,涵盖颜色处理、字符串操作、数学计算等,支持颜色对象运算,类型安全,边界值处理更严谨(如纯黑颜色调整),还可通过内置模块(如sass:math、sass:color)扩展功能。 内置函数相对基础,主要支持简单的颜色调整(如lighten、darken),函数参数和返回值类型不统一,颜色操作仅支持字符串拼接,无法参与复杂运算,易出现解析异常。
生态与框架支持 生态更成熟,社区活跃,插件丰富,主流框架(Bootstrap 4+、Angular、Vue CLI)均优先支持,与Webpack、Vite等构建工具集成流畅,Dart Sass编译速度快,适合大型项目。 生态相对小众,早期用于Bootstrap 3,目前在部分企业级老项目中仍有应用,与构建工具集成时存在配置限制(如Vite不支持javascriptEnabled配置),适合小型项目或老项目维护。
学习门槛 中等,SCSS语法兼容CSS,基础用法易上手,但高级特性(逻辑控制、模块化)需额外学习,缩进式语法对格式要求较高。 低,语法完全贴近原生CSS,无额外格式要求,基础功能简单易懂,适合刚接触预处理器的开发者快速上手。

二、核心语法对比(附代码示例)

以下针对最常用的核心功能,对比两者的语法差异,所有示例均采用各自最主流的语法(Sass用SCSS,Less用默认语法)。

1. 变量声明与使用

// 定义全局变量,设置默认值(未定义时生效)
$primary-color: #2563eb !default;
$font-size: 16px;

// 局部变量(仅在.box内生效,不影响全局)
.box {
  $local-color: #6c757d;
  color: $primary-color; // 全局变量:#2563eb
  background: $local-color; // 局部变量:#6c757d
  font-size: $font-size; // 全局变量:16px
}

// 重定义带!default的变量(合法,覆盖默认值)
$primary-color: #1d4ed8;
.text {
  color: $primary-color; // 覆盖后:#1d4ed8
}
// 定义全局变量
@primary-color: #2563eb;
@font-size: 16px;

// 局部变量(覆盖全局变量)
.box {
  @primary-color: #6c757d;
  color: @primary-color; // 局部变量:#6c757d(覆盖全局)
  font-size: @font-size; // 全局变量:16px
}

// 重定义变量(直接覆盖,无报错)
@primary-color: #1d4ed8;
.text {
  color: @primary-color; // 覆盖后:#1d4ed8
}

2. 选择器嵌套与父选择器

.nav {
  width: 100%;
  height: 60px;
  
  // 子选择器嵌套
  > li {
    float: left;
    margin: 0 10px;
    
    // 父选择器&(严格解析,无空格)
    &:hover {
      color: $primary-color;
    }
    &.active {
      font-weight: bold;
    }
  }
}
// 编译后无冗余,&解析准确
.nav {
  width: 100%;
  height: 60px;
  
  // 子选择器嵌套(与Sass一致)
  > li {
    float: left;
    margin: 0 10px;
    
    // 父选择器&(宽松解析,允许空格)
    & :hover { // 多余空格,编译为.nav li :hover(非预期)
      color: @primary-color;
    }
    &.active {
      font-weight: bold;
    }
  }
}
// 易因空格问题导致样式异常,需格外注意

3. 混合(Mixins)用法

// 定义带参数、默认值的混合
@mixin flex-center($direction: row) {
  display: flex;
  flex-direction: $direction;
  justify-content: center;
  align-items: center;
}

// 调用混合(传递参数)
.box {
  @include flex-center(column);
  width: 300px;
  height: 200px;
}

// 调用混合(使用默认值)
.card {
  @include flex-center;
  background: #fff;
}
// 定义带参数、默认值的混合(加括号不输出到CSS)
.flex-center(@direction: row) {
  display: flex;
  flex-direction: @direction;
  justify-content: center;
  align-items: center;
}

// 调用混合(直接使用类名,传递参数)
.box {
  .flex-center(column);
  width: 300px;
  height: 200px;
}

// 调用混合(使用默认值)
.card {
  .flex-center;
  background: #fff;
}

4. 继承用法

// 占位符选择器(仅用于继承,不输出到CSS)
%button-base {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

// 继承占位符样式
.primary-btn {
  @extend %button-base;
  background: $primary-color;
  color: #fff;
}

// 继承占位符样式
.success-btn {
  @extend %button-base;
  background: #16a34a;
  color: #fff;
}
// 编译后无%button-base相关样式,无冗余
// 普通类选择器(会被编译到CSS中)
.button-base {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

// 继承普通类样式
.primary-btn {
  @extend .button-base;
  background: @primary-color;
  color: #fff;
}

// 继承普通类样式
.success-btn {
  @extend .button-base;
  background: #16a34a;
  color: #fff;
}
// 编译后会保留.button-base样式,产生冗余

5. 逻辑控制用法

// 条件判断
$theme: dark;
.box {
  @if $theme == dark {
    background: #111;
    color: #fff;
  } @else {
    background: #fff;
    color: #333;
  }
}

// 循环(生成col-1到col-4)
@for $i from 1 to 5 {
  .col-#{$i} {
    width: 100% / $i;
    float: left;
  }
}
// 条件判断(通过when)
@theme: dark;
.box when (@theme = dark) {
  background: #111;
  color: #fff;
}
.box when not (@theme = dark) {
  background: #fff;
  color: #333;
}

// 递归循环(需手动终止)
.loop(@n) when (@n > 0) {
  .col-@{n} {
    width: 100% / @n;
    float: left;
  }
  .loop(@n - 1); // 递归调用,直到@n<=0
}
.loop(4); // 生成col-4到col-1

6. 模块化导入用法

// 1. 拆分模块:_variables.scss(局部文件,下划线开头不单独编译)
$primary-color: #2563eb;
$-private-var: 10px; // 私有成员(-/_开头,外部无法访问)

// 2. 主文件导入:main.scss
@use "./variables"; // 默认命名空间:variables,单例加载
@use "./variables" as v; // 自定义命名空间:v(重复导入仅加载一次)

.box {
  color: variables.$primary-color; // 通过命名空间访问
  padding: v.$primary-color;
  // margin: variables.$-private-var; // 报错:私有成员无法访问
}

// 3. 转发模块(供其他文件使用)
@forward "./variables" as var-*; // 转发所有成员,加前缀var-
// 1. 拆分模块:variables.less
@primary-color: #2563eb;
@private-var: 10px; // 无私有成员,外部可直接访问

// 2. 主文件导入:main.less
@import "./variables.less"; // 无命名空间,全局注入
@import "./variables.less"; // 重复导入,重复编译,产生冗余

.box {
  color: @primary-color; // 直接访问,无隔离
  padding: @private-var; // 无私有限制,可访问所有成员
}

三、编译环境与工具集成

1. Sass 编译环境

  • 主流实现:目前推荐使用Dart Sass(npm安装:npm install -g sass),替代已废弃的Node Sass,编译速度快、兼容性好,支持所有新特性。
  • 工具集成:与Webpack(sass-loader)、Vite(内置支持)、VS Code(Live Sass Compiler插件)集成流畅,支持source map调试,additionalData选项可注入全局变量,支持函数回调配置。
  • 编译命令:sass input.scss output.css(实时监听:sass --watch input.scss:output.css)。

2. Less 编译环境

  • 安装方式:基于Node.js,npm安装:npm install -g less,可通过less.js在浏览器端直接解析(仅适合开发调试,生产环境不推荐)。
  • 工具集成:与Webpack(less-loader)、Vite(内置支持)集成,但存在配置限制(如Vite不支持javascriptEnabled: true,无法运行JS表达式),additionalData选项仅支持字符串配置,不支持函数回调。
  • 编译命令:lessc input.less output.css(实时监听需借助第三方工具)。

四、项目选型建议

选型核心:结合项目规模、团队技术栈、功能需求,而非单纯追求“更强大”,优先保证开发效率和可维护性。

1. 优先选择 Sass(SCSS)的场景

  • 中大型项目/团队协作:需要复杂逻辑控制(如动态主题、批量样式生成)、严格的模块化隔离,避免命名冲突,Sass的@use/@forward、私有成员、逻辑控制等特性可大幅提升可维护性和协作效率。
  • 新项目开发:追求长期可维护性,希望适配主流技术生态,Sass的社区支持更完善、框架兼容性更好,后续扩展更便捷,是目前官方和行业推荐的首选方案。
  • 需要丰富的内置函数和高级特性:如复杂颜色处理、自定义函数、灵活的变量配置(!default),适合搭建设计系统或多主题项目,Sass的类型安全和函数链式调用更稳定可靠。
  • 使用主流前端框架:如Bootstrap 4+、Angular、Vue 3,这些框架均优先支持Sass,集成更流畅,减少配置成本。

2. 优先选择 Less 的场景

  • 小型项目/快速原型开发:需求简单,仅需变量、嵌套、基础混合等功能,Less语法贴近原生CSS,上手快、配置简单,可快速完成开发任务。
  • 维护旧项目:项目已基于Less开发,短期内无法迁移,继续使用Less可降低迁移成本,避免影响项目正常运行,Less的兼容性可保证旧代码稳定编译。
  • 团队成员不熟悉预处理器:团队以原生CSS开发为主,Less学习成本低,无需额外学习复杂语法,可快速过渡到预处理器开发模式。
  • 简单的浏览器端调试需求:Less可通过less.js直接在浏览器端解析,无需搭建复杂的编译环境,适合快速调试样式

五、常见问题与避坑指南

实际开发中,无论是Sass还是Less,都容易遇到语法、编译或集成相关的问题,以下梳理高频坑点及解决方案,帮助规避不必要的麻烦。

1. Sass 常见避坑点

  • 坑点1:混淆Node Sass与Dart Sass,导致编译报错。解决方案:彻底卸载Node Sass(npm uninstall node-sass),安装Dart Sass(npm install sass),确保项目依赖中无node-sass,避免版本冲突。
  • 坑点2:@use导入路径错误,提示“找不到模块”。解决方案:导入时省略下划线和文件后缀(如导入_variables.scss,写@use "./variables"),路径以当前文件为基准,避免绝对路径,跨目录导入需正确拼接相对路径(如@use "../utils/variables")。
  • 坑点3:误将SCSS语法用在缩进式Sass文件中,导致编译失败。解决方案:统一项目语法风格,优先使用SCSS(.scss后缀),若使用缩进式Sass(.sass后缀),需严格遵循“无大括号、无分号、靠缩进区分代码块”的规则。
  • 坑点4:重定义未加!default的变量,导致报错。解决方案:全局公共变量建议加!default(方便后续覆盖),局部变量仅在当前模块内使用,避免与全局变量重名,若需重定义全局变量,确保先导入变量文件,再重定义。

2. Less 常见避坑点

  • 坑点1:父选择器&添加多余空格,导致样式解析异常。解决方案:严格控制&与后续选择器的空格(如&:hover而非& :hover),避免编译后生成非预期的选择器(如.nav li :hover)。
  • 坑点2:Vite项目中启用javascriptEnabled: true,导致编译报错。解决方案:Vite内置的Less编译器不支持该配置,若需运行JS表达式,可改用Webpack+less-loader,或避免在Less中写入JS逻辑。
  • 坑点3:多次导入同一文件,导致CSS冗余。解决方案:尽量减少重复导入,可将公共模块(如变量、混合)集中在一个入口文件导入,再引入该入口文件,避免多文件重复导入同一模块。
  • 坑点4:变量覆盖导致样式异常,难以排查。解决方案:规范变量命名(如加模块前缀@btn-primary-color),避免全局变量与局部变量重名,复杂项目可按模块拆分变量文件,减少覆盖风险。

3. 通用避坑点

  • 避免嵌套过深:无论是Sass还是Less,嵌套层级建议不超过3层,否则会编译出冗长的选择器,影响CSS性能,且不利于代码维护。
  • 规范文件命名:局部模块文件(不单独编译的文件)建议以下划线开头(如_variables.scss_mixins.less),区分全局入口文件,避免编译生成多余的CSS文件。
  • 慎用!important:预处理器中尽量避免使用!important,若需提高样式优先级,可通过调整选择器权重(如增加父选择器)实现,否则会导致样式优先级混乱,难以调试。

六、实战对比总结

Sass和Less本质上都是为了解决原生CSS的痛点,提升样式开发效率,但两者的定位和适用场景有明显区分,无需纠结“谁更好”,只需结合自身需求选择即可,核心总结如下:

  • 从功能强大度来看:Sass > Less,Sass的模块化、逻辑控制、内置函数等高级特性,更适合复杂项目和设计系统搭建,能解决更多场景下的开发痛点。
  • 从学习成本来看:Less < Sass,Less语法与原生CSS高度一致,上手门槛极低,适合新手或原生CSS开发者快速过渡,Sass的高级特性需要额外投入时间学习。
  • 从生态和未来趋势来看:Sass更具优势,官方持续更新维护,主流框架和构建工具优先支持,Node Sass的废弃也推动了Dart Sass的普及,而Less生态相对停滞,仅适合维护旧项目或小型项目。
  • 从团队协作来看:Sass更适合团队协作,严格的作用域、命名空间和私有成员机制,能有效避免命名冲突,清晰的依赖关系也便于代码维护和迭代;Less无模块化隔离,大型团队协作易出现问题。

最后补充一句:无论是选择Sass还是Less,核心是“规范使用”——统一语法风格、合理拆分模块、规范变量命名,才能真正发挥预处理器的优势,让样式代码更高效、更易维护。如果是新建项目,优先选择Sass(SCSS),贴合行业主流;如果是维护旧项目或快速开发,Less也是不错的选择。

七、快速选型对照表(便捷参考)

项目/团队情况 推荐选择 核心原因
中大型项目、团队协作 Sass(SCSS) 模块化强、无命名冲突、支持复杂逻辑,可维护性高
小型项目、快速原型开发 Less 上手快、配置简单,满足基础需求,开发效率高
新建项目、追求长期维护 Sass(SCSS) 生态成熟、官方推荐,适配主流框架,扩展便捷
旧项目维护(基于Less) Less 降低迁移成本,保证旧代码稳定编译,无需额外学习
新手开发者、原生CSS过渡 Less 语法贴近原生CSS,学习成本低,快速上手无压力
搭建设计系统、多主题项目 Sass(SCSS) 内置函数丰富、变量配置灵活,支持复杂动态样式生成

SCSS中@use与@import的区别

2026年4月10日 10:16

SCSS(Sassy CSS)中@use@import均用于实现样式模块化,实现代码复用,但二者在作用域、加载机制、命名空间等核心特性上差异显著。其中@import是SCSS早期的导入语法,存在全局污染、重复加载等问题,而@use是Sass 3.8+推出的新版模块化语法,旨在解决@import的缺陷,目前已被官方推荐作为首选导入方式,未来@import将逐步被弃用。

一、核心差异对比(表格清晰呈现)

对比维度 @import(旧版语法) @use(新版推荐)
作用域 全局作用域,导入的变量、混合宏(mixin)、函数会直接注入当前文件的全局作用域,易造成命名冲突和变量污染。 局部作用域,导入的内容被封装在独立模块中,需通过命名空间访问,从根本上避免全局污染和命名冲突。
加载机制 多次导入同一文件时,会重复加载、重复编译,增加编译时间,可能导致输出CSS冗余。 自动实现单例加载,无论导入多少次同一文件,仅加载、编译一次,提升编译效率,避免冗余代码。
命名空间 无命名空间,导入的所有成员(变量、mixin等)可直接访问,无需前缀,易引发命名冲突,需通过冗长命名规避冲突。 默认以导入文件的文件名作为命名空间,也可自定义命名空间;访问成员时需加上命名空间前缀,可通过as *省略前缀(慎用)。
私有成员支持 不支持私有成员,导入文件中所有定义的变量、mixin均可被外部访问,无法实现成员隐藏。 支持私有成员,以-_为前缀的变量、mixin视为私有,仅能在定义文件内部使用,外部无法访问,实现更好的封装性。
依赖关系 依赖关系混乱,无法清晰判断变量、mixin的来源,不利于大型项目维护和团队协作。 依赖关系显式化,通过命名空间可明确知道每个成员的来源,代码可维护性大幅提升,适合大型项目和团队协作。
变量配置 通过重定义变量覆盖默认值(需在@import前定义),但全局变量易被意外修改,配置逻辑不清晰。 支持通过with语句针对性配置模块变量,不影响全局,配置逻辑更严谨、可追溯。
官方支持 已被官方不推荐使用,计划逐步弃用,仅为兼容旧代码保留,部分新特性不支持。 官方推荐首选语法,支持所有新特性,是SCSS模块化开发的标准方案,与@forward配合实现更完善的模块化体系。

上述表格已清晰列出@use@import的所有核心区别,接下来我们重点拆解最影响开发效率和代码质量的两个特性——重复加载和命名空间。

二、重点特性详解(重复加载+命名空间)

2.1 重复加载(性能与冗余核心差异)

重复加载是@import最突出的问题之一,会直接影响样式文件性能和代码冗余度,而@use通过单例加载机制完美解决了这一问题。

  • 当使用 @import 导入模块时,如果在多个文件中多次导入同一个文件,会导致重复加载的问题。
  • 这意味着被导入的文件将在每个使用了 @import 的文件中都被加载一次,导致样式表中包含多份相同的样式,从而影响性能和增加文件大小。

我们通过一个实际示例,直观感受重复加载的问题:

// _variables.scss(被重复导入的模块)
$primary-color: #007bff;
$secondary-color: #6c757d;

// styles1.scss
@import 'variables';
body {
  background-color: $primary-color;
}

// styles2.scss
@import 'variables';
button {
  background-color: $secondary-color;
}

示例解析:我们有两个样式文件 styles1.scss 和 styles2.scss,它们分别使用 @import 导入了同一个 _variables.scss 文件。由于 styles1.scss 和 styles2.scss 都导入了 _variables.scss,在编译这两个样式文件时,_variables.scss 将被加载两次。

编译后的结果如下所示:

// 编译后的 styles1.cssbody {
  background-color: #007bff;
}

// 编译后的 styles2.cssbutton {
  background-color: #6c757d;
}

可以看到,虽然编译后的CSS中未直接显示重复的变量定义,但 _variables.scss 中的内容在编译过程中被加载了两次,不仅增加了编译时间,若模块中包含实际样式(而非仅变量),会导致CSS文件中出现多份相同样式,增加文件大小、影响页面加载性能,还可能引发潜在的样式冲突。

而使用 @use 导入方式可以避免重复加载问题,因为它会确保每个模块只加载一次,即使在多个文件中导入。这样可以优化编译性能,并保持样式表的精简和一致性。

2.2 命名空间(避免冲突的核心机制)

@import 没有命名空间机制,这是导致其命名冲突的核心原因;而 @use 内置命名空间功能,可灵活隔离模块成员,提升代码可读性和可维护性,具体分为三种使用场景。

2.2.1 不使用as:直接以文件名作为命名空间

当在 @use 后面直接跟上文件路径,且不使用 as 关键字指定命名空间时,会将导入的模块整体作为一个命名空间,且使用被导入文件的名称作为命名空间标识(省略下划线前缀)。

// _variables.scss
$primary-color: #007bff;
$secondary-color: #6c757d;

// styles.scss
@use 'variables.scss';  // 省略下划线,默认命名空间为variables
body {   
  background-color: variables.$primary-color; // 通过命名空间访问变量
}  
button {   
  background-color: variables.$secondary-color;
}

示例解析:styles.scss 使用 @use 直接导入了 variables.scss 文件,未指定自定义命名空间,因此 _variables.scss 中的所有内容被封装在 variables 命名空间下,访问时需加上 variables.前缀,避免与当前文件的其他变量冲突。

2.2.2 使用as xxx:自定义命名空间

通过 as 关键字可以为导入的模块自定义命名空间,让命名更简洁、贴合业务场景,进一步提升代码可读性。

// _variables.scss(与上例一致)
$primary-color: #007bff;
$secondary-color: #6c757d; 

// styles.scss
@use 'variables.scss' as customVars;  // 自定义命名空间为customVars
body {   
  background-color: customVars.$primary-color; 
}  
button {   
  background-color: customVars.$secondary-color; 
}

示例解析:通过 as customVars 为导入的模块创建了自定义命名空间 customVars,后续访问模块中的变量时,需通过 customVars. 前缀,既避免了命名冲突,又让变量来源更清晰。

2.2.3 特殊情况:使用as * 导入(无命名空间)

如果在 @use 后面使用 as *,表示将导入的模块的所有内容直接合并到当前文件中,不创建任何命名空间,导入的变量、mixin、函数等可直接使用。

// _variables.scss(与上例一致)
$primary-color: #007bff;
$secondary-color: #6c757d;

// styles.scss
@use 'variables.scss' as *;  // 无命名空间,直接合并内容
body {   
  background-color: $primary-color; // 直接访问变量,无需前缀
}  
button {   
  background-color: $secondary-color;
}

注意:这种方式会丧失命名空间的隔离优势,与 @import 类似,易引发命名冲突,仅建议在变量统一管理、无冲突风险的简单场景使用。

三、语法用法补充(其他核心用法)

3.1 @import 其他常见问题

除了重复加载,@import 还存在全局污染、依赖混乱等问题,以下是基础用法回顾及问题总结:

// _variables.scss
$color: red;
$font-size: 16px;

// _utils.scss
$color: blue; // 与variables.scss中的$color重名
@mixin flex-center {
  display: flex;
  justify-content: center;
  align-items: center;
}

// main.scss
@import "./variables";
@import "./utils"; // 重名变量被覆盖
@import "./variables"; // 重复加载,增加编译冗余

.box {
  color: $color; // 输出blue(被utils.scss中的$color覆盖,意外污染)
  font-size: $font-size; // 输出16px
  @include flex-center;
}

编译后CSS:

.box {
  color: blue;
  font-size: 16px;
  display: flex;
  justify-content: center;
  align-items: center;
}

问题总结:@import导入的变量会全局覆盖,重复导入同一文件会重复编译,无法区分成员来源,维护难度高。

3.2 @use 其他优势用法

除了命名空间和单例加载,@use 还支持私有成员、变量配置等优势特性,以下是补充示例:

// _theme.scss
$-private-var: 10px; // 私有变量(仅文件内可用,前缀-/_)
$primary-color: #3498db !default; // 默认变量,可被配置覆盖
$secondary-color: #2ecc71 !default;

// main.scss
// 自定义命名空间为t,并通过with配置变量
@use "./theme" as t with (
  $primary-color: #e74c3c, // 覆盖默认主色
  $secondary-color: #f39c12
);

.box {
  background: t.$primary-color; // 输出#e74c3c(配置后的值)
  color: t.$secondary-color; // 输出#f39c12(配置后的值)
  // margin: t.$-private-var; // 报错:私有变量无法访问
}

编译后CSS:

.box {
  background: #e74c3c;
  color: #f39c12;
}

四、实际开发场景选择建议

1. 优先使用@use的场景

  • 新建SCSS项目:全程使用@use,配合@forward实现模块化拆分(如按变量、mixin、组件拆分文件),提升代码可维护性和可扩展性。
  • 大型项目/团队协作:通过命名空间隔离和显式依赖,避免命名冲突,清晰区分成员来源,降低协作成本和维护难度。
  • 需要封装私有成员:当部分变量、mixin仅需在当前文件使用,无需暴露给外部时,使用@use的私有成员特性,实现代码封装。
  • 使用Sass新特性:@use支持所有Sass新特性(如内置模块导入),而@import不支持部分新特性,无法适配未来升级需求。

2. 临时使用@import的场景

  • 维护旧项目:当项目中大量使用@import,短期内无法全部迁移时,可临时保留,逐步替换为@use,避免影响项目正常运行。
  • 导入纯CSS文件:虽然@use也可导入纯CSS文件,但@import语法更简洁,且无需处理命名空间(仅适用于简单场景)。

五、补充注意事项

  • @use导入文件时,可省略文件扩展名(.scss、.sass),也可省略下划线前缀(如导入_variables.scss,可写为@use "./variables")。
  • @use的as *语法可省略命名空间,直接访问导入的成员,但会丧失命名空间的隔离优势,易引发冲突,仅建议在变量统一管理的简单场景使用。
  • 迁移旧项目时,需注意:@use中以-_开头的变量视为私有,若旧代码中存在此类命名的变量,导入后会无法访问,需修改变量命名或调整访问方式。
  • @use与@forward的配合:@forward用于转发模块成员(不直接使用),适合库开发或入口文件整合;@use用于直接使用模块成员,二者配合可实现更灵活的模块化体系。
  • Sass内置模块(如sass:math、sass:color)需通过@use导入才能使用,无法通过@import导入,这也是官方推荐@use的重要原因之一。

六、总结

推荐使用 @use 来导入模块,以获得更好的模块化支持、性能优化和避免全局污染问题。其核心优势在于局部作用域、命名空间隔离、单例加载、私有成员支持,彻底解决了@import的全局污染、重复加载、依赖混乱等问题,提升了代码的可维护性、可扩展性和协作效率。而 @import 在新版本 Sass 中已不再推荐使用,并且未来可能会被废弃,仅适合临时维护旧项目,新建项目或项目升级时,应优先采用@use + @forward的模块化方案,遵循官方推荐的开发规范,避免后续维护成本增加。

学习Less,看这篇就够了(从入门到实战)

2026年4月10日 09:58

Less(Leaner Style Sheets)是CSS预处理器,在原生CSS基础上增加变量、嵌套、混合、函数、运算、模块化等编程特性,让CSS更易维护、复用、扩展,最终编译成标准CSS运行。本文从环境搭建、核心语法、进阶技巧、实战规范全流程覆盖,新增多个优雅使用案例(含Less源码与编译后CSS对比),直接上手可用。

一、Less基础:是什么、为什么、怎么用

1.1 核心优势(为什么用Less)

  • 变量统一管理:颜色、尺寸、字体等全局配置,一改全改
  • 嵌套结构:完全匹配HTML层级,代码更直观、减少重复选择器
  • 混合(Mixin):复用样式片段,像函数一样传参
  • 运算/函数:支持加减乘除、颜色明暗、单位转换
  • 模块化:拆分文件、导入合并,便于团队协作与维护
  • 兼容原生CSS:所有CSS代码可直接写在Less中,零学习门槛

1.2 环境搭建(3种方式,最快1分钟)

方式1:浏览器直接运行(学习/原型)


<!-- 1. 引入Less文件,rel必须是stylesheet/less -->
<link rel="stylesheet/less" type="text/css" href="styles.less" />
<!-- 2. 引入Less编译器(CDN,已替换为可用链接) -->
<script src="https://cdn.bootcdn.net/ajax/libs/less.js/4.2.0/less.min.js"></script>

⚠️ 仅适合开发调试,生产环境禁止使用(性能差、依赖JS);原CDN链接报错“link dead”,已替换为稳定可用版本

方式2:VSCode自动编译(推荐,日常开发)

  1. 安装插件:Easy LESS
  2. 新建.less文件,保存时自动生成同名.css
  3. 配置(可选,settings.json):

"less.compile": {
  "out": "../css/", // 输出到css文件夹
  "compress": true, // 压缩CSS
  "sourceMap": false
}

方式3:命令行编译(项目构建)

  1. 安装Node.js,全局安装Less:

npm install -g less
lessc -v # 验证安装
  1. 编译命令:

lessc styles.less styles.css # 基础编译
lessc styles.less styles.min.css --compress # 压缩输出

二、Less核心语法(必掌握,直接套用)

2.1 变量(@变量名:值)—— 统一管理,一改全改

变量以@开头,可存颜色、尺寸、字体、路径等,支持插值(选择器、属性、URL)。


// 1. 基础变量定义(语义化命名,便于维护)
@primary: #2563eb; // 主色
@success: #16a34a; // 成功色
@font-size: 16px; // 基础字体
@spacing: 20px; // 基础间距
@img-path: "../images"; // 图片路径

// 2. 变量使用(结合运算,减少硬编码)
.btn {
  background: @primary;
  font-size: @font-size;
  padding: @spacing/2; // 10px,无需手动计算
  margin: @spacing;
}

// 3. 变量插值(复用选择器、属性、URL,避免重复书写)
@selector: card;
@prop: width;
.@{selector} { // 编译为 .card
  @{prop}: 300px; // 编译为 width:300px
  background: url("@{img-path}/bg.png");
}

编译后CSS:


.btn {
  background: #2563eb;
  font-size: 16px;
  padding: 10px;
  margin: 20px;
}
.card {
  width: 300px;
  background: url("../images/bg.png");
}

优雅要点:变量语义化命名,通过插值复用选择器和路径,运算替代硬编码,后续修改主色、间距时,仅需修改变量值,无需逐行修改样式。

2.2 嵌套规则(&父选择器)—— 匹配HTML结构,告别重复

Less允许选择器嵌套,&代表当前父选择器,用于伪类、交集选择器、兄弟选择器,避免重复书写父选择器。


// HTML结构:<div class="header"><nav class="nav"><a href="#" class="active">首页</a></nav></div>
.header {
  width: 100%;
  height: 60px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1); // 增加阴影,提升质感
  .nav {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 100%;
    a {
      color: #333;
      text-decoration: none;
      padding: 0 @spacing/2; // 复用间距变量
      margin: 0 @spacing;
      // & 代表父选择器 .nav a,避免书写 .nav a:hover
      &:hover { 
        color: @primary;
        transition: color 0.3s ease; // 过渡效果,更优雅
      }
      &.active { 
        font-weight: bold; 
        color: @primary;
        border-bottom: 2px solid @primary;
      }
    }
  }
}

编译后CSS(对比:选择器自动拼接,无需手动重复):


.header {
  width: 100%;
  height: 60px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header .nav {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
}
.header .nav a {
  color: #333;
  text-decoration: none;
  padding: 0 10px;
  margin: 0 20px;
}
.header .nav a:hover {
  color: #2563eb;
  transition: color 0.3s ease;
}
.header .nav a.active {
  font-weight: bold;
  color: #2563eb;
  border-bottom: 2px solid #2563eb;
}

优雅要点:嵌套结构与HTML完全对应,可读性极强;&的使用避免重复书写父选择器(如.header .nav a),同时结合过渡效果,提升交互质感。

2.3 混合(Mixin)—— 复用样式,支持传参(核心)

Mixin是可复用的样式片段,带()不输出到CSS,不带()会输出;支持参数、默认值、条件判断,可封装公共样式,避免重复编码。

(1)基础Mixin(无参)—— 封装公共样式


// 定义:清除浮动(公共Mixin,可全局调用)
.clearfix() {
  &::after {
    content: "";
    display: block;
    clear: both;
    height: 0;
    visibility: hidden;
  }
}

// 定义:居中布局(公共Mixin,复用性强)
.center() {
  display: flex;
  justify-content: center;
  align-items: center;
}

// 使用:多个容器复用,无需重复书写样式
.container {
  .clearfix(); // 调用清除浮动
  width: 1200px;
  margin: 0 auto;
}
.modal {
  .center(); // 调用居中布局
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.5);
}

编译后CSS(对比:Mixin样式自动注入,无需重复书写):


.container {
  width: 1200px;
  margin: 0 auto;
}
.container::after {
  content: "";
  display: block;
  clear: both;
  height: 0;
  visibility: hidden;
}
.modal {
  display: flex;
  justify-content: center;
  align-items: center;
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0,0,0,0.5);
}

(2)带参数Mixin(默认值、多参数)—— 动态生成样式


// 定义:按钮样式(带参数+默认值,灵活适配不同场景)
.btn(@bg: @primary, @color: #fff, @radius: 4px, @padding: 8px 16px) {
  display: inline-block;
  padding: @padding;
  background: @bg;
  color: @color;
  border-radius: @radius;
  cursor: pointer;
  border: none;
  outline: none;
  transition: all 0.3s ease; // 统一过渡效果
  &:hover { 
    filter: brightness(0.9); // hover变暗,无需单独写样式
    transform: translateY(-2px); // 轻微上浮,提升交互
  }
  &:active {
    transform: translateY(0);
  }
}

// 使用:按需传参,无需重复书写按钮基础样式
.btn-primary { .btn(); } // 使用默认值(主色按钮)
.btn-success { .btn(@success); } // 传单个参数(成功色按钮)
.btn-round { .btn(@primary, #fff, 50%, 10px 20px); } // 传全部参数(圆形按钮)
.btn-small { .btn(@primary, #fff, 4px, 4px 8px); } // 传部分参数(小尺寸按钮)

编译后CSS(对比:自动生成不同样式的按钮,代码简洁):


.btn-primary {
  display: inline-block;
  padding: 8px 16px;
  background: #2563eb;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  border: none;
  outline: none;
  transition: all 0.3s ease;
}
.btn-primary:hover {
  filter: brightness(0.9);
  transform: translateY(-2px);
}
.btn-primary:active {
  transform: translateY(0);
}

.btn-success {
  display: inline-block;
  padding: 8px 16px;
  background: #16a34a;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  border: none;
  outline: none;
  transition: all 0.3s ease;
}
.btn-success:hover {
  filter: brightness(0.9);
  transform: translateY(-2px);
}
.btn-success:active {
  transform: translateY(0);
}

.btn-round {
  display: inline-block;
  padding: 10px 20px;
  background: #2563eb;
  color: #fff;
  border-radius: 50%;
  cursor: pointer;
  border: none;
  outline: none;
  transition: all 0.3s ease;
}
.btn-round:hover {
  filter: brightness(0.9);
  transform: translateY(-2px);
}
.btn-round:active {
  transform: translateY(0);
}

.btn-small {
  display: inline-block;
  padding: 4px 8px;
  background: #2563eb;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  border: none;
  outline: none;
  transition: all 0.3s ease;
}
.btn-small:hover {
  filter: brightness(0.9);
  transform: translateY(-2px);
}
.btn-small:active {
  transform: translateY(0);
}

(3)命名空间(组织Mixin,避免冲突)


// 命名空间:统一管理公共Mixin,避免与业务样式冲突
#utils() { // 带(),不输出到CSS
  .clearfix() { 
    &::after {
      content: "";
      display: block;
      clear: both;
      height: 0;
      visibility: hidden;
    }
  }
  .center() { 
    display: flex;
    justify-content: center;
    align-items: center; 
  }
  .shadow() { // 新增阴影Mixin
    box-shadow: 0 2px 12px rgba(0,0,0,0.1);
  }
}

// 调用:通过命名空间调用,清晰区分公共样式与业务样式
.box { 
  #utils.center(); 
  #utils.shadow();
  width: 300px;
  height: 200px;
  background: #fff;
}

编译后CSS:


.box {
  display: flex;
  justify-content: center;
  align-items: center;
  box-shadow: 0 2px 12px rgba(0,0,0,0.1);
  width: 300px;
  height: 200px;
  background: #fff;
}

2.4 运算(+ - * /)—— 自动计算,减少手动计算

支持颜色、数值、单位运算,Less自动处理单位(优先左侧单位),避免手动计算错误,代码更优雅。


@base: 20px;
@width: 1000px;
@card-width: @width / 5; // 200px,自动计算栅格宽度
@light-primary: lighten(@primary, 10%); // 主色变亮10%,无需手动计算色值

.box {
  width: @width - 40px; // 960px,自适应宽度
  padding: @base * 1.5; // 30px,间距按比例调整
  margin: (@base / 2); // 10px,统一间距
}
.card {
  width: @card-width;
  background: @light-primary;
  margin: @base;
}

⚠️ 除法建议加括号:(100px / 2),避免与CSS语法冲突

编译后CSS(对比:自动计算数值和颜色,无需手动计算):


.box {
  width: 960px;
  padding: 30px;
  margin: 10px;
}
.card {
  width: 200px;
  background: #3b82f6; // 自动计算的亮主色
  margin: 20px;
}

2.5 内置函数—— 颜色、字符串、数学处理(常用)

Less提供大量内置函数,无需定义直接用,提升效率,让样式更优雅。


// 颜色函数(最常用,自动处理色值)
@primary: #2563eb;
@dark-primary: darken(@primary, 10%); // 主色变暗10%
@fade-primary: fade(@primary, 50%); // 主色半透明
@saturate-primary: saturate(@primary, 20%); // 主色增加饱和度

// 数学函数(自动处理数值)
@base-font: 16px;
@title-font: ceil(@base-font * 1.5); // 24px,向上取整
@sub-font: floor(@base-font * 0.8); // 12px,向下取整

// 使用:结合函数和变量,样式更灵活
.title {
  font-size: @title-font;
  color: @dark-primary;
}
.sub-title {
  font-size: @sub-font;
  color: @fade-primary;
}
.btn {
  background: @saturate-primary;
  &:hover {
    background: @dark-primary;
  }
}

编译后CSS:


.title {
  font-size: 24px;
  color: #1d4ed8; // 变暗后的主色
}
.sub-title {
  font-size: 12px;
  color: rgba(37, 99, 235, 0.5); // 半透明主色
}
.btn {
  background: #1d4ed8; // 增加饱和度后的主色
}
.btn:hover {
  background: #1d4ed8;
}

2.6 导入(@import)—— 模块化拆分,代码解耦

拆分变量、Mixin、公共样式,通过@import合并,支持省略.less后缀,让代码结构更清晰,便于维护。


// 1. 拆分文件(按功能拆分,各司其职)
// variables.less → 全局变量(单独管理,一改全改)
@primary: #2563eb;
@font-size: 16px;
@spacing: 20px;

// mixins.less → 公共混合(单独管理,全局复用)
.clearfix() { ... }
.btn(@bg: @primary) { ... }
.center() { ... }

// base.less → 基础样式(reset、全局样式)
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: "Microsoft YaHei", sans-serif;
  font-size: @font-size;
  color: #333;
}

// 2. 主文件导入(统一入口,结构清晰)
@import "variables"; // 省略.less
@import "mixins";
@import "base";

// 编写业务样式(仅关注业务,无需关注公共样式)
.header {
  .clearfix();
  height: 60px;
  .nav {
    .center();
    a {
      color: #333;
      &:hover { color: @primary; }
    }
  }
}

⚠️ @import (reference) "mixins.less"; → 仅导入Mixin,不输出到CSS

编译后CSS(对比:所有导入的样式自动合并,结构清晰):


* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  font-family: "Microsoft YaHei", sans-serif;
  font-size: 16px;
  color: #333;
}
.header {
  height: 60px;
}
.header::after {
  content: "";
  display: block;
  clear: both;
  height: 0;
  visibility: hidden;
}
.header .nav {
  display: flex;
  justify-content: center;
  align-items: center;
}
.header .nav a {
  color: #333;
}
.header .nav a:hover {
  color: #2563eb;
}

2.7 作用域与注释

  • 作用域:就近原则,局部变量覆盖全局变量,类似JS,可灵活控制变量作用范围

// 全局变量(整个项目可用)
@color: red;

// 局部变量(仅在.box内可用,不影响全局)
.box {
  @color: blue;
  color: @color; // blue(局部优先)
}
// 其他模块仍使用全局变量
.text {
  color: @color; // red
}

编译后CSS:


.box {
  color: blue;
}
.text {
  color: red;
}
  • 注释:

    • 单行注释:// 注释 → 编译后不保留(用于开发备注,不污染生产CSS)
    • 多行注释:/* 注释 */ → 编译后保留(用于生产环境备注,如版权信息)

三、Less进阶技巧(提升效率,避坑)

3.1 父选择器&高级用法


// 1. 前缀拼接(批量生成同类样式,避免重复)
.btn {
  display: inline-block;
  padding: 8px 16px;
  border-radius: 4px;
  &-primary { background: @primary; color: #fff; } // .btn-primary
  &-success { background: @success; color: #fff; } // .btn-success
  &-warning { background: #f59e0b; color: #fff; } // .btn-warning
  &-disabled { background: #ccc; color: #666; cursor: not-allowed; } // .btn-disabled
}

// 2. 多层嵌套&(精准定位子元素,避免冗长选择器)
.list {
  width: 100%;
  &-item {
    padding: @spacing;
    border-bottom: 1px solid #eee;
    &:last-child { border-bottom: none; } // .list-item:last-child
    &-title { font-weight: bold; color: #333; } // .list-item-title
    &-content { color: #666; margin-top: 8px; } // .list-item-content
  }
}

编译后CSS(对比:自动拼接选择器,批量生成样式,代码简洁):


.btn {
  display: inline-block;
  padding: 8px 16px;
  border-radius: 4px;
}
.btn-primary {
  background: #2563eb;
  color: #fff;
}
.btn-success {
  background: #16a34a;
  color: #fff;
}
.btn-warning {
  background: #f59e0b;
  color: #fff;
}
.btn-disabled {
  background: #ccc;
  color: #666;
  cursor: not-allowed;
}

.list {
  width: 100%;
}
.list-item {
  padding: 20px;
  border-bottom: 1px solid #eee;
}
.list-item:last-child {
  border-bottom: none;
}
.list-item-title {
  font-weight: bold;
  color: #333;
}
.list-item-content {
  color: #666;
  margin-top: 8px;
}

3.2 条件Mixin(when)—— 动态生成样式


// 定义:根据尺寸生成不同按钮(条件判断,灵活适配)
.btn(@size) when (@size = large) {
  padding: 12px 24px;
  font-size: 18px;
  border-radius: 6px;
}
.btn(@size) when (@size = small) {
  padding: 4px 8px;
  font-size: 14px;
  border-radius: 3px;
}
// 新增条件:根据主题生成不同颜色
.btn(@size, @theme) when (@theme = dark) {
  .btn(@size);
  background: #333;
  color: #fff;
}

// 使用:按需传入条件,自动生成对应样式
.btn-lg { .btn(large); } // 大尺寸按钮
.btn-sm { .btn(small); } // 小尺寸按钮
.btn-lg-dark { .btn(large, dark); } // 大尺寸深色按钮

编译后CSS:


.btn-lg {
  padding: 12px 24px;
  font-size: 18px;
  border-radius: 6px;
}
.btn-sm {
  padding: 4px 8px;
  font-size: 14px;
  border-radius: 3px;
}
.btn-lg-dark {
  padding: 12px 24px;
  font-size: 18px;
  border-radius: 6px;
  background: #333;
  color: #fff;
}

3.3 循环(for)—— 批量生成样式(Less 3.9+)


// 生成1-5列栅格(循环遍历,无需手动写5个样式)
.generate-columns(@n, @i: 1) when (@i =< @n) {
  .col-@{i} {
    width: (@i * 100% / @n);
    float: left;
    padding: @spacing/2;
    box-sizing: border-box;
  }
  .generate-columns(@n, @i + 1); // 递归循环
}
.generate-columns(5); // 生成col-1到col-5

// 生成不同尺寸的margin样式(批量生成,复用性强)
.generate-margin(@n, @i: 1) when (@i =< @n) {
  .mt-@{i} { margin-top: @i * 8px; }
  .mb-@{i} { margin-bottom: @i * 8px; }
  .generate-margin(@n, @i + 1);
}
.generate-margin(5); // 生成mt-1~mt-5、mb-1~mb-5

编译后CSS(对比:自动生成10个margin样式+5个栅格样式,无需手动书写):


.col-1 {
  width: 20%;
  float: left;
  padding: 10px;
  box-sizing: border-box;
}
.col-2 {
  width: 40%;
  float: left;
  padding: 10px;
  box-sizing: border-box;
}
.col-3 {
  width: 60%;
  float: left;
  padding: 10px;
  box-sizing: border-box;
}
.col-4 {
  width: 80%;
  float: left;
  padding: 10px;
  box-sizing: border-box;
}
.col-5 {
  width: 100%;
  float: left;
  padding: 10px;
  box-sizing: border-box;
}

.mt-1 { margin-top: 8px; }
.mb-1 { margin-bottom: 8px; }
.mt-2 { margin-top: 16px; }
.mb-2 { margin-bottom: 16px; }
.mt-3 { margin-top: 24px; }
.mb-3 { margin-bottom: 24px; }
.mt-4 { margin-top: 32px; }
.mb-4 { margin-bottom: 32px; }
.mt-5 { margin-top: 40px; }
.mb-5 { margin-bottom: 40px; }

3.4 映射(Maps)—— 像对象一样取值(Less 3.5+)


// 定义颜色映射(类似JS对象,统一管理所有颜色,便于查找和修改)
@colors: {
  primary: #2563eb;
  success: #16a34a;
  warning: #f59e0b;
  danger: #ef4444;
  dark: #333;
  light: #f5f5f5;
};

// 定义尺寸映射(统一管理尺寸,避免硬编码)
@sizes: {
  small: 14px;
  base: 16px;
  large: 18px;
  xlarge: 24px;
};

// 使用:通过映射取值,代码更简洁,维护更方便
.btn {
  font-size: @sizes[base];
  &-primary { background: @colors[primary]; }
  &-success { background: @colors[success]; }
  &-warning { background: @colors[warning]; }
}
.title {
  font-size: @sizes[xlarge];
  color: @colors[dark];
}

编译后CSS:


.btn {
  font-size: 16px;
}
.btn-primary {
  background: #2563eb;
}
.btn-success {
  background: #16a34a;
}
.btn-warning {
  background: #f59e0b;
}
.title {
  font-size: 24px;
  color: #333;
}

四、实战规范与常见问题(避坑指南)

4.1 项目规范(推荐)

  1. 文件结构:

src/
├── less/
│   ├── variables.less   # 全局变量(颜色、尺寸、字体)
│   ├── mixins.less      # 公共混合(清除浮动、按钮、居中)
│   ├── base.less        # 基础样式(reset、全局)
│   ├── components/      # 组件(按钮、卡片、导航)
│   └── main.less        # 主入口(导入所有)
  1. 命名:变量用@xxx-xxx(@primary-color),Mixin用小驼峰/短横线,语义化

4.2 常见问题与解决

  1. 嵌套过深:最多3层,避免编译后选择器过长、性能差
  2. 变量污染:全局变量放单独文件,局部变量仅在模块内使用
  3. 编译报错:检查括号、分号、变量定义,优先用Easy LESS实时提示
  4. 单位冲突:运算时统一单位,或用unit()函数转换

五、Less vs Sass(快速对比,选择更合适)

特性 Less Sass
变量符号 @ $
编译环境 Node.js/浏览器 Ruby/Node.js
语法 接近CSS,易上手 缩进/花括号两种
循环/条件 支持(Less 3.9+) 原生支持,更强大
生态 轻量,适合中小型项目 功能全,适合大型项目

六、总结与下一步

Less核心就是把CSS变成可维护的代码:变量统一、嵌套清晰、Mixin复用、函数简化、模块化拆分。新增的优雅案例均贴合实际开发,通过Less源码与编译后CSS对比,可清晰看到Less如何简化编码、提升效率。

AI全栈入门指南:一文搞清楚NestJs 中的 Controller 和路由

作者 Moment
2026年4月10日 09:48

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

上一节我们已经跑通了第一个 NestJS 项目,也看到了 ControllerService 是如何配合的。这一节继续往前走,专门看 Controller 到底负责什么,以及路由在 NestJS 里是怎么声明出来的。

如果只用一句话概括,Controller 做的是"把一个 HTTP 请求接进来,再把结果返回出去"。至于真正的业务逻辑,通常不应该堆在控制器里,而是交给 Service

定义路由

NestJS 里,路由不是写在一张单独的表里,而是直接声明在控制器类和它的方法上。

类上的 @Controller() 用来定义这一组接口的公共路径。方法上的 @Get()@Post() 这类装饰器,用来定义具体某个接口对应的请求方式和子路径。

下面这段代码演示了一个很常见的写法:

import { Controller, Get } from "@nestjs/common";

@Controller("users")
export class UsersController {
  @Get()
  findAll(): string {
    return "all users";
  }

  @Get("profile")
  findProfile(): string {
    return "user profile";
  }
}

这段代码的含义分别是:

  • @Controller('users') 表示这一组接口都挂在 /users 下面
  • @Get() 对应 GET /users
  • @Get('profile') 对应 GET /users/profile

你可以把控制器理解成"某一类资源或某一块功能的入口集合"。比如用户相关接口放进 UsersController,订单相关接口放进 OrdersController。这样路径组织和代码组织会更一致。

GETPOSTPUTDELETE

NestJS 对常见 HTTP 方法都提供了对应装饰器。最常用的就是 @Get()@Post()@Put()@Delete()

下面这个例子把常见写法放在一起看,会更直观:

import {
  Body,
  Controller,
  Delete,
  Get,
  Param,
  Post,
  Put,
} from "@nestjs/common";

interface CreateUserDto {
  name: string;
  email: string;
}

interface UpdateUserDto {
  name?: string;
}

@Controller("users")
export class UsersController {
  @Get()
  findAll(): string {
    return "get all users";
  }

  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }

  @Put(":id")
  update(
    @Param("id") id: string,
    @Body() body: UpdateUserDto,
  ): { id: string; body: UpdateUserDto } {
    return { id, body };
  }

  @Delete(":id")
  remove(@Param("id") id: string): { deletedId: string } {
    return { deletedId: id };
  }
}

第一次看这段代码时,先不要急着记所有装饰器。先抓住一个核心规律:

不同 HTTP 方法,本质上就是在告诉框架,"同样是某个路径,这次应该用哪种请求方式来匹配它"。

通常可以先这样理解它们的语义:

  • GET 用来读取数据
  • POST 用来创建数据
  • PUT 用来整体更新数据
  • DELETE 用来删除数据

这不是绝对规则,但它是最常见的约定。按照这个约定设计接口,团队协作时会更容易理解。

Path 参数、Query 参数、Body 参数

写接口时大半时间在跟入参打交道。浏览器和客户端把数据放在 URL 路径里、问号后面或请求体里,NestJS 用三种装饰器一一对应,名字和业务含义基本对齐,读方法签名就能猜出数据从哪来。

下面这段代码放在同一个 PostsController 里:上面是带路径段和查询串的 GET,下面是读 JSON 体的 POST

import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";

interface CreatePostDto {
  title: string;
  content: string;
}

@Controller("posts")
export class PostsController {
  @Get(":id")
  findOne(
    @Param("id") id: string,
    @Query("preview") preview?: string,
  ): { id: string; preview?: string } {
    return { id, preview };
  }

  @Post()
  create(@Body() body: CreatePostDto): CreatePostDto {
    return body;
  }
}

对应关系可以这样记:

  • @Param() 对应路径里的动态段,/posts/123 里的 123 会进 id
  • @Query() 对应 ? 后面的键值,/posts/123?preview=true 里的 preview 会进来,没有则 previewundefined(这里写了可选参数)
  • @Body() 对应报文主体,常见于 POSTPUTPATCH 提交的 JSON 或表单序列化结果

更短的一句口诀是,路径用 @Param(),问号后用 @Query(),包体用 @Body()

这样一来,控制器里很少出现"这段到底读的是 req 的哪一块"的猜测。来源都写在参数列表上,也比到处翻 req.paramsreq.queryreq.body 更直观。

Header、状态码、重定向

除了读路径和请求体,控制器有时候还需要读取请求头、设置状态码,或者做重定向。NestJS 也提供了比较声明式的写法。

先看请求头的读取方式:

import { Controller, Get, Headers } from "@nestjs/common";

@Controller("info")
export class InfoController {
  @Get()
  getClient(@Headers("user-agent") userAgent?: string): { userAgent?: string } {
    return { userAgent };
  }
}

这里的 @Headers('user-agent') 就是在读取请求头中的 user-agent

如果你想显式设置状态码,也可以这样写:

import { Controller, HttpCode, Post } from "@nestjs/common";

@Controller("users")
export class UsersController {
  @Post("login")
  @HttpCode(200)
  login(): { message: string } {
    return { message: "login success" };
  }
}

这个例子里,虽然是 POST 请求,但我们明确把返回状态码设成了 200。这在登录接口这类场景里很常见。

如果你想做重定向,可以使用 @Redirect()

import { Controller, Get, Redirect } from "@nestjs/common";

@Controller()
export class AppController {
  @Get("docs")
  @Redirect("https://docs.nestjs.com", 302)
  goDocs(): void {}
}

这段代码的意思是,当用户访问 /docs 时,服务端直接把请求重定向到指定地址。

所以这一节可以先总结成一句话:

控制器不只负责匹配路径,它还负责把请求中的关键信息拿出来,并按需要影响最终响应行为。

返回值与原生 response 的区别

这是很多初学者刚接触 NestJS 时容易困惑的一点。

在大多数情况下,你只需要"直接返回值"就够了。比如返回对象、数组、字符串,NestJS 会帮你把这些结果自动序列化并发送给客户端。

例如下面这种写法,就是最推荐的默认方式:

import { Controller, Get } from "@nestjs/common";

@Controller("health")
export class HealthController {
  @Get()
  check(): { status: string } {
    return { status: "ok" };
  }
}

它的好处是,代码简洁,也更容易和 Interceptor、异常过滤器、状态码装饰器这些机制配合。

NestJS 也允许你拿到原生响应对象,比如 Express 下的 response。这种方式适合你需要手动控制响应细节的场景,比如流式输出、文件下载、特殊响应头等。

写法通常像这样:

import { Controller, Get, Res } from "@nestjs/common";
import type { Response } from "express";

@Controller("download")
export class DownloadController {
  @Get()
  download(@Res() res: Response): void {
    res.status(200).json({ message: "manual response" });
  }
}

一旦你使用了 @Res(),就意味着这一段响应由你自己接管。框架不会再按默认方式帮你自动返回结果。

所以两种方式的区别可以先这样理解:

  • 直接 return,更符合 NestJS 的默认风格,日常接口优先使用
  • 使用原生 response,控制力更强,但你需要自己负责响应的发送

对初学者来说,有一个很实用的判断标准:

如果只是普通的 JSON 接口,优先直接 return。 只有当你确实需要精细控制响应过程时,再考虑使用 @Res()

小结

ControllerNestJS 里承担的是请求入口角色。它负责定义路由、读取参数、组织响应,但不应该承载过多业务逻辑。

这一篇最重要的收获,可以先落成下面几件事:

  • 路由通过控制器类和方法上的装饰器来声明
  • GETPOSTPUTDELETE 对应不同的 HTTP 请求方式
  • @Param()@Query()@Body() 分别读取不同来源的参数
  • @Headers()@HttpCode()@Redirect() 可以影响请求处理和响应行为
  • 普通接口优先直接 return,原生 response 适合特殊控制场景

下一节,我们会继续从控制器往下走,看看 ServiceProviderModule 是怎样把业务能力真正组织起来的。

别再迷信"优化":大多数性能问题根本不在代码里

作者 LeonGao
2026年4月10日 09:24

前言:一个性能优化的常见幻觉

"这段代码太慢了,我得优化一下。"

我几乎每天都能听到这句话。程序员们拿着Profiler的输出,找到一个"热点函数",然后兴冲冲地开始重构:把ArrayList换成LinkedList,把循环展开,把递归改成尾递归,把SQL语句加上子查询......

三周后,他们发现:性能没有改善。

这不是一个孤立的案例。根据我的观察,大约70%的性能问题,其瓶颈根本不在代码层面。你花了两周时间优化的那个算法,对整体延迟的贡献可能不到1%。

问题的根源,可能在网络、在数据库、在GC、在架构设计,甚至在——说出来你可能不信——没有给服务器接网线

本文将系统性地颠覆你对"性能优化"的认知,告诉你为什么代码层面的优化往往收效甚微,以及真正的性能瓶颈藏在哪里


第一部分:你以为的性能优化,和真实的性能优化

1.1 一个让你怀疑人生的实验

在我正式开始讲理论之前,我想请你做一个思想实验。

假设你负责一个API服务,它的P99延迟是2000ms,用户怨声载道。老板说:"给我们优化到200ms以内。"

你信心满满地开始分析,发现:

环节 耗时 占比
数据库查询 1800ms 90%
业务逻辑计算 50ms 2.5%
序列化/反序列化 50ms 2.5%
网络传输 50ms 2.5%
其他 50ms 2.5%

数据库占了90%,这很明显。你开始疯狂优化SQL:加索引、重写查询、用Redis缓存......

一周后,数据库查询时间从1800ms降到了500ms。

P99延迟变成了多少?

700ms

你优化了64%的数据库耗时,但整体延迟只改善了36%。

这说明什么?

木桶效应:系统的性能取决于最短的那块木板,但如果你只优化那一块,其他木板可能成为新的瓶颈。

1.2 性能优化的认知陷阱

让我们来分析一下为什么这么多人迷信"代码优化":

陷阱一:代码是可见的,其他是不可见的

可见度排序:
代码 > 配置 > 中间件 > 网络 > 硬件

优化意愿排序:
代码 > 配置 > 中间件 > 网络 > 硬件("这个我改不了"

我们倾向于优化我们看得见、改得了的地方,而不是真正影响性能的地方。

陷阱二:工具会撒谎

当你打开Profiler,看到:

Hot Functions:
1. com.example.service.UserService.getUserById() - 45%
2. com.example.service.OrderService.getOrderList() - 30%
3. com.example.util.StringHelper.format() - 10%

你会不会想:"UserService.getUserById()占了45%,我得优化它!"

但这个45%是什么?是自用时间(Self Time) ,也就是函数本身执行的时间,不包括它调用的其他函数。

如果getUserById()调用了UserDao.queryById(),而queryById()是一个数据库查询,那么:

getUserById() 的自用时间:45ms(只是内存操作)
getUserById() 的总时间:   1500ms(包含1500ms的数据库查询)

实际热点:数据库查询,不是"代码"

陷阱三:局部优化 vs 全局优化

局部优化:在给定的系统状态下,优化某个函数
全局优化:改变系统状态,消除瓶颈

例子:
- 优化算法:局部优化 ✓✓✓
- 增加索引:全局优化 ✓✓✓
- 减少GC:局部优化 ✓✓✓  
- 扩容:全局优化 ✓✓✓

大多数人会选择局部优化,因为它看起来更"技术含量",但实际上全局优化往往能带来数量级的提升。


第二部分:性能问题的真实分布

2.1 我观察到的性能问题分布

根据过去几年诊断过的上百个性能问题,我总结出一个大概的分布:

性能问题根因分布(基于案例统计):

数据库问题     ████████████████████  35%
    ├── 慢查询(缺少索引、查询写法)
    ├── 连接池配置不当
    └── 锁竞争、死锁

网络问题       ██████████████        25%
    ├── 跨地域延迟
    ├── 网络抖动、丢包
    └── DNS解析、连接建立

架构问题       ████████████          20%
    ├── 单点瓶颈(同步串行改并行)
    ├── 不必要的调用(重复请求)
    └── 数据模型设计问题

配置问题       ████████              15%
    ├── JVM参数
    ├── 连接池大小
    ├── 超时配置
    └── 日志级别

代码问题       ████                  5%
    ├── 真正低效的算法
    ├── 内存泄漏
    └── 资源未释放

其他           ██                    5%

关键洞察:数据库+网络+架构问题占了80% ,而代码问题只占5%

2.2 为什么数据库问题占比这么高?

数据库是大多数应用的性能瓶颈,原因很朴素:

数据库是"有状态"的中心节点

        应用实例 A
        应用实例 B    ──────→  [  数据库  ]
        应用实例 C              (单一数据源)
        应用实例 D
           ...
        应用实例 N

一个数据库,同时被N个应用实例访问
数据库的性能 = 所有访问的共同瓶颈

当你的应用扩展到10个实例时,如果每个实例每秒发1000个查询,数据库每秒要处理10000个查询。数据库的性能决定了系统的上限

2.3 为什么网络问题占比这么高?

尤其是在微服务架构中:

一次请求的延迟构成:
│
│  ██                                      ██
│  ██  ██████████████████████████████████████
│  ██  ██                    ██             ██
│  ██  ██  ██                ██             ██
│  ██  ██  ██  ████████████████             ██
│  ██  ██  ██  ██                             
│  ██  ██  ██  ██  ████                      
│  ██  ██  ██  ██  ██  ██                   
│  ██  ██  ██  ██  ██  ██  ██                
│  ██  ██  ██  ██  ██  ██  ██  ██            
├──────────────────────────────────────────────┤
▲                                              ▲
网络     序列化   业务    数据库   结果    网络
建立              计算            查询    返回

Legend: 网络建立(30%) | 序列化(10%) | 业务计算(5%) | 数据库(45%) | 其他(10%)

在微服务架构中,一次API调用可能涉及:

  • 网络建立(DNS、TCP握手、TLS握手)
  • 序列化/反序列化
  • 业务逻辑
  • 远程调用(又是网络)
  • 数据库查询
  • 结果返回

业务逻辑只占5% ,你能优化的空间能有多大?


第三部分:数据库性能问题诊断

3.1 慢查询:最常见的数据库瓶颈

慢查询的定义

sql
-- MySQL: 超过long_query_time(默认10秒)的查询
-- PostgreSQL: 超过log_min_duration_statement的查询

-- 查看MySQL慢查询日志配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询日志
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;  -- 超过1秒记录

慢查询分析四步法

第一步:识别慢查询

sql
-- MySQL: 使用EXPLAIN分析
EXPLAIN SELECT u.*, o.* 
FROM users u 
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active' 
  AND u.created_at > '2024-01-01';

-- 输出示例:
-- id: 1
-- select_type: SIMPLE
-- table: u
-- type: ALL          ← 全表扫描!危险信号
-- possible_keys: NULL
-- key: NULL
-- rows: 1000000       ← 检查了100万行!
-- Extra: Using where

-- PostgreSQL: 使用EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM users WHERE email = 'test@example.com';

第二步:分析执行计划

sql
-- 检查表结构
SHOW CREATE TABLE users;

-- 检查索引
SHOW INDEX FROM users;

-- 分析索引使用情况
-- id=1, type=ref 表示使用索引,type=ALL 表示全表扫描
-- key 列显示实际使用的索引
-- rows 列显示预计扫描的行数

第三步:常见问题与修复

问题 特征 解决方案
全表扫描 type=ALL, key=NULL 添加合适索引
索引失效 Using filesort, Using temporary 避免函数/隐式转换
大量回表 Using index condition 覆盖索引
连接顺序 - FORCE INDEX / 统计信息更新
sql
-- 问题1:隐式类型转换导致索引失效
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- phone是VARCHAR类型,传入INT导致全表扫描

-- 修复:
EXPLAIN SELECT * FROM users WHERE phone = '13800138000';

-- 问题2:函数导致索引失效
EXPLAIN SELECT * FROM orders WHERE DATE(created_at) = '2024-01-01';
-- DATE()函数导致无法使用索引

-- 修复:范围查询
EXPLAIN SELECT * FROM orders 
WHERE created_at >= '2024-01-01 00:00:00' 
  AND created_at < '2024-01-02 00:00:00';

-- 问题3:模糊匹配导致索引失效
EXPLAIN SELECT * FROM users WHERE email LIKE '%@example.com';
-- 前导通配符无法使用索引

-- 修复:考虑全文索引或ES

第四步:验证修复效果

sql
-- 修复前后对比
SET SESSION profiling = 1;

SELECT * FROM users WHERE email = 'test@example.com';
SELECT * FROM users WHERE phone = '13800138000';

SHOW PROFILES;
-- 查看执行时间

3.2 连接池问题:被忽视的瓶颈

连接池配置诊断

yaml
# Spring Boot配置示例
spring:
  datasource:
    hikari:
      maximum-pool-size: 20        # 最大连接数
      minimum-idle: 5              # 最小空闲
      connection-timeout: 30000    # 获取连接超时(ms)
      idle-timeout: 600000         # 空闲超时(ms)
      max-lifetime: 1800000        # 连接最大生命周期(ms)
      connection-test-query: SELECT 1

连接池问题的典型症状

症状:应用响应时间偶尔暴增,等待时间长

排查:
1. 查看活跃连接数 vs 最大连接数
   - HikariCP: metrics.hikaricp.connections.active
   - Druid: druid.stat.workingCount

2. 查看等待连接的线程数
   - 如果 > 0,说明连接不够用

3. 查看连接等待时间
   - connection-timeout 应该是主要瓶颈指标

连接池配置公式

最小连接数 = (核心线程数 / 单请求所需连接数) × 1.2
最大连接数 = CPU核心数 × 2 + 磁盘数

对于Web应用(单请求1个DB连接):
最小连接数 = 线程池核心大小 × 1.2
最大连接数 = 线程池最大大小 × 1.2

参考值:
- 4核8G机器:maximum-pool-size = 20-50
- 8核16G机器:maximum-pool-size = 50-100

3.3 锁竞争:并发杀手

sql
-- PostgreSQL: 查看当前锁等待
SELECT 
    pg_blocking_pids(pid) AS blocked_by,
    pid,
    usename,
    query,
    state,
    wait_event_type,
    wait_event
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

-- MySQL: 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看正在锁定的事务
SELECT 
    trx_id,
    trx_state,
    trx_mysql_thread_id,
    trx_query,
    trx_started,
    trx_rows_locked,
    trx_tables_locked
FROM information_schema.INNODB_TRX;

锁问题的常见原因

问题 原因 解决方案
长事务持有锁 事务内包含大量操作 拆分事务、及时提交
缺少索引 更新扫描全表,锁定多行 添加索引
锁粒度大 全表锁 优化SQL只锁定必要行
死锁 多个事务互相等待 调整操作顺序

第四部分:网络性能问题诊断

4.1 网络延迟:从"能通"到"够快"

网络延迟构成

总延迟 = DNS解析 + TCP连接建立 + TLS握手 + 数据传输 + 服务器处理 + ACK返回

实际数字(假设物理距离1000km):
├── DNS解析:     5-50ms(通常可缓存)
├── TCP握手:     1ms × 往返次数(RTT = 5ms时,3次握手 = 10ms)
├── TLS握手:     5-15ms(1-RTT vs 0-RTT)
├── 数据传输:     RTT × 数据包数量(通常1-2个RTT)
└── 服务器处理:   1-100ms(取决于应用)

总延迟 = 5 + 10 + 10 + 10 + 5 = 40ms(理想情况)

延迟诊断工具

bash
# 基础延迟测试
ping -c 10 api.example.com

# 输出示例:
# --- api.example.com ping statistics ---
# 10 packets transmitted, 10 received, 0% packet loss, time 9012ms
# rtt min/avg/max/mdev = 5.234/5.456/5.678/0.123 ms

# 如果avg > 20ms,说明有额外延迟

# 详细路由分析
traceroute api.example.com  # Linux
tracert api.example.com     # Windows

# 监控持续延迟
mtr api.example.com  # Linux/Mac(pingtraceroute组合)

网络问题常见原因与修复

问题1:DNS解析延迟
├── 症状:首次请求很慢,后续请求正常
├── 原因:DNS未缓存,TTL过期
└── 修复:
    ├── 客户端:DNS缓存、HTTP DNS(HttpDNS)
    ├── 服务端:降低TTL预热、Anycast
    └── DNS服务器:DNS预取

问题2:TCP连接复用不足
├── 症状:每个请求都慢,没有明显规律
├── 原因:短连接、连接断开重连
└── 修复:
    ├── 启用HTTP Keep-Alive
    ├── 使用连接池
    ├── HTTP/2多路复用
    └── gRPC长连接

问题3:跨地域延迟
├── 症状:特定地区用户慢
├── 原因:物理距离远
└── 修复:
    ├── 就近接入(CDN/边缘节点)
    ├── 数据同步(读写分离)
    └── 协议优化(QUIC/WireGuard)

问题4:网络抖动
├── 症状:延迟忽高忽低,P99很高但avg正常
├── 原因:丢包、重传、路由不稳定
└── 修复:
    ├── BBR/CUBIC拥塞控制
    ├── 前向纠错(FEC)
    └── 多路冗余传输

4.2 连接超时:被低估的风险

yaml
# Spring Feign超时配置
feign:
  client:
    default:
      connect-timeout: 5000      # 连接建立超时
      read-timeout: 30000        # 读取数据超时

# Spring RestTemplate超时配置
RestTemplate restTemplate = new RestTemplate();
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(5000);      // 5秒
factory.setReadTimeout(30000);        // 30秒
restTemplate.setRequestFactory(factory);

# OkHttp超时配置
OkHttpClient client = new OkHttpClient.Builder()
    .connectTimeout(5, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .writeTimeout(30, TimeUnit.SECONDS)
    .build();

超时配置的原则

超时不是随便设的,要基于SLA计算:

假设我们的SLA:
- P99响应时间 ≤ 500ms
- 核心服务不可用时间 ≤ 1%

那么超时配置:
├── 核心链路超时 = SLA × 80% = 400ms
│   └── 原因:要给重试留空间
│
├── 非核心服务超时 = 核心超时 × 50% = 200ms
│   └── 原因:非核心超时不应影响核心链路
│
└── 降级阈值 = 超时时间 × 1.5 = 600ms
    └── 原因:超过这个时间就降级

4.3 带宽瓶颈:数据传输的隐形杀手

bash
# 查看网卡带宽使用
ethtool eth0
# 输出:
# Speed: 10000Mb/s  ← 10Gbps网卡
# Duplex: Full

# 查看实际带宽使用
sar -n DEV 1 10
# IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s   %ifutil
# eth0    1234.56   2345.67   12345.67  23456.78    12.34

# 带宽计算
实际带宽 = rxkB/s × 8 / 1000  # Mbps
利用率 = 实际带宽 / 网卡带宽 × 100%

# 如果利用率持续 > 70%,说明带宽可能成为瓶颈

带宽优化策略

策略 效果 适用场景
压缩 节省50-80%带宽 JSON、文本数据
分页 减少单次传输量 大数据返回
字段过滤 按需返回字段 GraphQL
增量更新 只传变化的部分 实时数据同步
CDN 静态资源本地化 图片、视频、JS/CSS
协议升级 HTTP/2, gRPC 通用

第五部分:架构层面的性能问题

5.1 同步调用 vs 异步调用

同步调用的问题

java
// 同步调用:总时间 = A + B + C + D = 100 + 200 + 300 + 400 = 1000ms
public OrderDetail getOrderDetail(Long orderId) {
    User user = userService.getUser(orderId);       // 100ms
    Address address = addressService.getAddress(user.getAddressId());  // 200ms
    Payment payment = paymentService.getPayment(orderId);             // 300ms
    List<Item> items = itemService.getItems(orderId);                // 400ms
    
    return new OrderDetail(user, address, payment, items);
}

异步调用优化

java
// 异步调用:总时间 = max(A, B, C, D) = 400ms
public OrderDetail getOrderDetail(Long orderId) {
    CompletableFuture<User> userFuture = userService.getUserAsync(orderId);
    CompletableFuture<Address> addressFuture = userService.getAddressAsync(orderId);
    CompletableFuture<Payment> paymentFuture = paymentService.getPaymentAsync(orderId);
    CompletableFuture<List<Item>> itemsFuture = itemService.getItemsAsync(orderId);
    
    // 等待所有结果
    CompletableFuture.allOf(userFuture, addressFuture, paymentFuture, itemsFuture).join();
    
    return new OrderDetail(
        userFuture.get(),
        addressFuture.get(),
        paymentFuture.get(),
        itemsFuture.get()
    );
}

// 性能提升:1000ms → 400ms(提升60%)

5.2 N+1查询问题

问题演示

java
// N+1查询:1次查用户 + N次查订单 = N+1次数据库查询
public List<UserOrderCount> getUserOrderCounts() {
    List<User> users = userDao.findAll();  // 1次查询,返回100个用户
    
    return users.stream()
        .map(user -> {
            int orderCount = orderDao.countByUserId(user.getId()); // N次查询
            return new UserOrderCount(user.getName(), orderCount);
        })
        .collect(Collectors.toList());
}

// 执行流程:
// Query 1: SELECT * FROM users
// Query 2: SELECT COUNT(*) FROM orders WHERE user_id = 1
// Query 3: SELECT COUNT(*) FROM orders WHERE user_id = 2
// ...
// Query 101: SELECT COUNT(*) FROM orders WHERE user_id = 100

// 总查询数:101次

解决方案一:JOIN查询

java
// 1次查询解决
public List<UserOrderCount> getUserOrderCounts() {
    return jdbcTemplate.query(
        "SELECT u.name, COUNT(o.id) as order_count " +
        "FROM users u LEFT JOIN orders o ON u.id = o.user_id " +
        "GROUP BY u.id, u.name",
        (rs, rowNum) -> new UserOrderCount(
            rs.getString("name"),
            rs.getInt("order_count")
        )
    );
}

// 执行流程:
// Query 1: SELECT u.name, COUNT(o.id) ... GROUP BY ...
// 总查询数:1次

解决方案二:批量查询

java
// 2次查询解决
public List<UserOrderCount> getUserOrderCounts() {
    // 1. 先查所有用户
    List<User> users = userDao.findAll();
    
    // 2. 批量查询订单数量
    List<Long> userIds = users.stream().map(User::getId).collect(Collectors.toList());
    Map<Long, Long> orderCountMap = orderDao.countByUserIds(userIds);
    
    return users.stream()
        .map(user -> new UserOrderCount(
            user.getName(), 
            orderCountMap.getOrDefault(user.getId(), 0L)
        ))
        .collect(Collectors.toList());
}

// 执行流程:
// Query 1: SELECT * FROM users
// Query 2: SELECT user_id, COUNT(*) FROM orders WHERE user_id IN (...) GROUP BY user_id
// 总查询数:2次

5.3 缓存使用:双刃剑

缓存命中率分析

缓存命中流程:
┌─────────┐    Hit    ┌─────────┐
│ Request │ ───────→ │  Cache  │ → 返回数据(<1ms)
└─────────┘           └─────────┘
     │
     │ Miss
     ▼
┌─────────┐    Hit    ┌─────────┐
│ Request │ ───────→ │  Cache  │ → 更新缓存 → 返回数据
└─────────┘           └─────────┘
     │                      ▲
     │ Miss                 │
     ▼                      │
┌─────────┐           ┌─────────┐
│   DB    │ → 读取数据 → │  Cache  │
└─────────┘           └─────────┘

缓存问题诊断

java
// 诊断代码:打印缓存命中率
public class CacheMetrics {
    private long hitCount = 0;
    private long missCount = 0;
    
    public <T> T get(String key, Supplier<T> loader) {
        T value = cache.get(key);
        if (value != null) {
            hitCount++;
            return value;
        }
        
        missCount++;
        value = loader.get();
        cache.put(key, value);
        return value;
    }
    
    public double getHitRate() {
        long total = hitCount + missCount;
        return total > 0 ? (double) hitCount / total : 0;
    }
    
    // 监控指标
    public Map<String, Object> getMetrics() {
        return Map.of(
            "hit_count", hitCount,
            "miss_count", missCount,
            "hit_rate", getHitRate(),
            "total_requests", hitCount + missCount
        );
    }
}

缓存三大经典问题

问题1:缓存穿透
├── 症状:大量请求查询不存在的数据,直接打到DB
├── 原因:缓存和DB都没有这条数据
└── 解决:
    ├── 布隆过滤器(判断数据是否存在)
    ├── 缓存空值(NULL值也要缓存,设置短TTL)
    └── 参数校验(拦截非法参数)

问题2:缓存击穿
├── 症状:某个热点key过期时,大量请求同时击穿到DB
├── 原因:单一热点key,高并发同时访问
└── 解决:
    ├── 互斥锁(只有一个请求查DB)
    ├── 永不过期(逻辑过期 + 异步更新)
    └── 多级缓存(L1本地 + L2 Redis)

问题3:缓存雪崩
├── 症状:大量缓存同时过期,系统崩溃
├── 原因:缓存同时失效 or 缓存服务宕机
└── 解决:
    ├── 过期时间随机化
    ├── 熔断降级
    ├── 高可用缓存(Redis Cluster)
    └── 预热(系统启动时加载热点数据)

第六部分:配置层面的性能问题

6.1 JVM调参:不是玄学

核心参数解析

bash
# 典型Web应用JVM配置(4核8G机器,堆大小4G)
java -Xms4g -Xmx4g \
     -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=200 \
     -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/log/oom.hprof \
     -Xlog:gc*:file=/var/log/gc.log:time,uptime:filecount=5,filesize=10M \
     -XX:+UseStringDeduplication \
     -Djava.security.egd=urandom \
     -jar app.jar

参数说明

参数 作用 建议值
-Xms/-Xmx 堆大小 相等,避免动态扩展
-XX:+UseG1GC GC收集器 JDK 11+默认,推荐使用
-XX:MaxGCPauseMillis GC暂停目标 200ms(不要过低)
-XX:ParallelGCThreads 并行GC线程数 CPU核数
-XX:ConcGCThreads 并发GC线程数 ParallelGCThreads * 0.25

GC问题诊断

bash
# 查看GC日志
cat /var/log/gc.log

# GC日志示例分析
[2024-01-15T10:30:12.123+0800][info][gc] GC(12345) G1Evacuation Pause (young) (盘点)
Before GC:
- Heap: 2048M used (50%), 4096M max
- GC Worker: 8 threads

After GC:
- Heap: 1024M used (25%), 4096M max
- Duration: 45ms

# 如果GC频率太高(<1秒一次)或暂停时间太长(>200ms),需要优化

6.2 中间件配置诊断

Redis配置检查

bash
# 查看Redis配置
CONFIG GET *

# 关键配置检查
CONFIG GET maxmemory
# 输出: maxmemory 2147483648  (2GB)

CONFIG GET maxmemory-policy
# 输出: maxmemory-policy allkeys-lru (LRU淘汰策略)

# 内存使用分析
INFO memory
# 输出示例:
# used_memory: 1234567890
# used_memory_human: 1.15G
# maxmemory: 2147483648
# maxmemory_human: 2.00G
# mem_fragmentation_ratio: 1.45  ← 如果>1.5,可能有内存碎片

# 查看慢查询
SLOWLOG GET 10
# 输出:[命令, 执行时间(微秒), 时间戳, 参数]

Tomcat/Undertow连接配置

yaml
# Spring Boot内嵌服务器配置
server:
  tomcat:
    threads:
      max: 200              # 最大工作线程数
      min-spare: 10        # 最小空闲线程
    accept-count: 100      # 队列长度
    max-connections: 10000 # 最大连接数
  
  undertow:
    io-threads: 4          # IO线程数
    worker-threads: 200    # 工作线程数
    buffer-size: 1024      # 缓冲区大小

# 线程数计算公式
# IO密集型:线程数 = CPU核心数 × 2
# CPU密集型:线程数 = CPU核心数 + 1
# 混合型:线程数 = CPU核心数 × (1 + IO等待时间/计算时间)

第七部分:性能优化的正确姿势

7.1 性能优化的正确流程

性能优化完整流程:

┌─────────────────────────────────────────────────────────┐
│ 第1步:定义问题                                            │
│ - 具体的性能指标(P99延迟、吞吐量、CPU使用率)               │
│ - 当前的基线值                                              │
│ - 目标值                                                   │
│ - 业务影响(用户能感知吗?)                                │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│ 第2步:建立测量体系                                         │
│ - 确认指标可测量                                            │
│ - 建立监控系统                                              │
│ - 设置告警                                                  │
│ - 确定复现路径                                              │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│ 第3步:分析瓶颈位置                                         │
│ - 自顶向下:请求 → 网关 → 服务 → 数据库                     │
│ - 瓶颈定位:CPU/内存/IO/网络/数据库                         │
│ - 不要猜测,基于数据                                         │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│ 第4步:制定优化方案                                         │
│ - 列出所有可能的方案                                         │
│ - 评估每个方案的成本和收益                                    │
│ - 优先级:影响大、成本低的先做                                │
│ - 预计提升幅度                                              │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│ 第5步:实施和验证                                           │
│ - 灰度发布/AB测试                                           │
│ - 对比优化前后的指标                                         │
│ - 确认无副作用                                              │
└─────────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────┐
│ 第6步:持续监控                                             │
│ - 确认性能稳定                                              │
│ - 记录优化效果                                              │
│ - 预防回退                                                  │
└─────────────────────────────────────────────────────────┘

7.2 优化优先级矩阵

                成本(开发+风险)
              低              高
         ┌──────────┬──────────┐
收益 高   │ ① 快速   │ ② 计划   │
         │  (做)  │  (规划) │
         ├──────────┼──────────┤
收益 低   │ ③ 跳过   │ ④ 搁置   │
         │  (不做) │  (放弃) │
         └──────────┴──────────┘

优先级:
① 先做:配置优化、简单缓存、索引添加
② 计划:架构调整、重构、协议升级
③ 跳过:微优化、不确定的改
④ 放弃:高风险低收益的方案

7.3 常见优化手段的效果对比

优化手段 实施难度 预期收益 风险 优先级
添加数据库索引 ⭐⭐⭐⭐⭐
扩大连接池 ⭐⭐⭐⭐
启用缓存 ⭐⭐⭐⭐
异步化调用 ⭐⭐⭐⭐
压缩传输数据 ⭐⭐⭐
JVM参数调优 ⭐⭐⭐
SQL重写 ⭐⭐⭐
算法优化 不一定 ⭐⭐
架构重构
语言/框架切换 极高 不一定 极高

结语:优化之前,先定位

这篇文章的核心观点只有一个:在你花两周时间优化算法之前,先确认你的瓶颈真的在算法上

大多数性能问题,都藏在数据库里、网络里、架构里、配置里。它们不像代码那样"可见",但它们的影响往往比代码优化大得多。

一个好的性能优化流程应该是:

  1. 1.测量:先确认问题存在,量化问题严重程度
  2. 2.定位:用数据找到瓶颈在哪里
  3. 3.验证:确认瓶颈位置后再动手
  4. 4.优化:从高收益、低成本的地方开始
  5. 5.验证:确认优化有效,无副作用

记住:优化不是万能药,诊断才是

下次当你想要"优化代码"的时候,问自己三个问题:

  1. 1.我测量过吗? → 知道问题在哪里
  2. 2.我定位过吗? → 知道瓶颈是什么
  3. 3.我验证过吗? → 知道优化有效

如果任何一个答案是否定的——放下代码,拿起监控,开始分析

这才是真正的性能优化。

你以为你会调试,其实只是会重启:一次讲清定位问题的底层逻辑

作者 LeonGao
2026年4月10日 09:22

前言:你是在调试,还是在祈祷?

"算了,重启一下试试。"——这句话大概是程序员日常工作中出现频率最高的"调试"手段。

我见过太多这样的场景:服务挂了,开发者不问为什么,先kubectl restart;接口报错了,先清缓存试试;程序跑不动了,先kill -9systemctl start。运气好了,问题消失,皆大欢喜;运气不好,同一个问题反复发作,最后变成"玄学问题"。

这不是调试,这是在祈祷

真正的调试是一门系统性的思维艺术,它要求你像侦探一样追踪线索、像科学家一样提出假设、像法官一样验证证据。本文将系统性地讲解定位问题的底层逻辑,帮你从"重启工程师"升级为真正的"问题终结者"。


第一部分:为什么你总是在重启?

在深入讨论调试方法论之前,我们需要先理解一个根本问题:为什么程序员倾向于用重启来"解决"问题?

1.1 重启的本质:一种认知懒惰

重启之所以流行,是因为它满足了一个关键心理:最小认知负荷原则

当你面对一个报错信息时,大脑会本能地评估两种策略的成本:

策略 认知成本 行动成本 结果确定性
理解问题并修复 高(需要分析日志、代码、上下文) 高(需要写代码、改配置) 不确定(可能找错方向)
重启服务 低(不需要理解问题) 低(一个命令) 概率性(可能好,可能坏)

在时间压力下,大脑会自动选择"认知成本低"的策略,哪怕它的"结果确定性"更差。这是一种理性的懒惰——在短期视角下,它确实是最优选择。

1.2 重启的问题:掩盖了真正的病因

重启能"解决"的问题,通常属于以下几类:

临时性故障:内存泄漏、连接池耗尽、文件句柄泄露。这类问题重启能清空状态,所以确实有效。

不可复现的bug:某些race condition、并发问题,重启后因为时序变化,可能就不再触发了。但这不意味着问题消失了,只是你没抓到它。

症状而非病因:服务A调用服务B超时,重启A能"解决"超时问题,但B的问题还在。迟早会再次爆发。

真正危险的是第三种情况。重启让你产生了"问题已解决"的错觉,但实际上问题的根源还在暗处生长

1.3 从重启到定位:一次认知升级

从"只会重启"到"能够定位问题",本质上是完成一次认知升级:

Level 1: 不知道发生了什么  重启
Level 2: 知道发生了什么现象  重启能解决
Level 3: 知道为什么发生  能预防
Level 4: 能复现和验证  能彻底修复

我们的目标,是帮你从Level 1跃升到Level 3甚至Level 4。


第二部分:定位问题的底层逻辑

2.1 问题空间与解空间的区分

在讨论具体方法之前,我们需要建立一个关键的概念框架:问题空间(Problem Space)和解空间(Solution Space)的区分

                    ┌─────────────────────────────────────┐
                    │         问题空间 Problem Space       │
                    │                                     │
                    │   [用户报告][现象][根因][影响]│
                    │       ↓         ↓       ↓        ↓  │
                    └─────────────────────────────────────┘
                                    ↓
                    ┌─────────────────────────────────────┐
                    │         解空间 Solution Space        │
                    │                                     │
                    │   [改代码][改配置][改架构][换方案]│
                    └─────────────────────────────────────┘

关键洞察:你看到的问题(用户报告的现象)只是问题空间的入口,而解空间(你准备改的代码)只是解决方案的一个选项。

大多数程序员的错误在于:直接从现象跳到解法,跳过了整个问题空间的分析。

2.2 黄金圈法则:从What到Why再到How

定位问题的标准思维框架,我称之为黄金圈法则(借用Simon Sinek的概念):

         Why (为什么)
            ▲
           ╱ ╲
          ╱   ╲
         ╱     ╲
        ╱   How ╲
       ╱  (怎么做) ╲
      ╱           ╲
     ╱    What    ╲
    ╱   (是什么)   ╲

What(是什么) :用户看到了什么现象?
Why(为什么) :为什么这个现象会发生?它的根本原因是什么?
How(怎么做) :我们应该如何修复/预防这个问题?

大多数人的思考顺序是 What → How,跳过Why。这就像医生看到发烧就开退烧药,而不追问感染源是什么。

2.3 问题定位的四步法

完整的定位问题流程包含以下四个步骤:

步骤一:现象收集(What)

收集一切与问题相关的外部表现:

markdown
1. 用户视角
   - 用户做了什么操作?
   - 用户期望得到什么结果?
   - 用户实际看到了什么?

2. 系统视角
   - 错误日志/错误码
   - 监控指标异常
   - 请求链路追踪
   - 服务依赖状态

常见错误:只收集了用户描述,而没有收集系统证据。

步骤二:假设生成(Why - 可能性)

基于现象,提出可能的根因假设:

markdown
假设层级:
├── 基础设施层
│   ├── 网络问题(延迟、丢包、DNS故障)
│   ├── 计算资源问题(CPU满、内存耗尽、磁盘IO瓶颈)
│   └── 依赖服务问题(上游服务不可用、响应超时)
│
├── 中间件层
│   ├── 数据库问题(连接池满、慢查询、死锁)
│   ├── 缓存问题(缓存穿透、缓存雪崩、Redis不可用)
│   └── 消息队列问题(消息积压、消费失败)
│
├── 应用逻辑层
│   ├── 代码bug(空指针、数组越界、业务逻辑错误)
│   ├── 配置问题(开关、参数、路由规则)
│   └── 边界条件(并发、幂等、事务边界)
│
└── 数据层
    ├── 数据质量问题(脏数据、编码问题)
    ├── 数据一致性问题(分布式事务)
    └── 数据边界问题(溢出、精度丢失)

关键原则:在这个阶段,不要过滤假设,把所有可能性都列出来。

步骤三:假设验证(Why - 排查)

通过证据来验证或排除假设:

验证方法优先级:
1. 直接证据
   - 日志分析(最直接、最可信)
   - 监控指标(有数据支撑)
   - 链路追踪(能还原调用路径)

2. 间接证据
   - 代码审查(通过代码逻辑推断)
   - 配置检查(当前状态快照)
   - 环境对比(测试vs生产)

3. 主动探测
   - 复现测试(能否在测试环境复现)
   - 灰度验证(只对部分用户生效)
   - 注入故障(Chaos Engineering)

关键原则:每个假设必须可证伪。如果一个假设无法被验证,也无法被推翻,那它不是一个合格的假设。

步骤四:根因确定(Why - 结论)

当所有其他假设都被排除,剩下的就是根因:

markdown
根因确认标准:
□ 能够完整解释所有观察到的现象
□ 有直接证据支持(不是推测)
□ 修复后问题不再复现
□ 修复是可逆的(回滚后问题会回来)
□ 修复是可测试的(可以写自动化测试验证)

第三部分:实战调试工具箱

3.1 日志分析:从噪音中提取信号

日志是调试的第一手资料,但大多数人的问题是:日志太多,看不过来

日志分析的三个层次

层次一:grep阶段(大多数人止步于此)

bash
# 搜索关键词
grep "ERROR" app.log

# 搜索多个关键词
grep -E "ERROR|FATAL" app.log

# 显示上下文
grep -C 5 "ERROR" app.log

层次二:模式识别阶段

bash
# 统计错误类型分布
grep "ERROR" app.log | cut -d' ' -f6 | sort | uniq -c | sort -rn

# 统计时间分布(查看错误集中时段)
grep "ERROR" app.log | awk '{print $2}' | cut -d: -f1,2 | sort | uniq -c

# 查找异常模式
grep -E "\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}" app.log | \
  awk -F'[ :]' '{print $1" "$2" "$3}' | \
  sort | uniq -c | sort -k1 -rn | head -20

层次三:关联分析阶段

python
# 伪代码:关联请求ID进行全链路追踪
log_data = parse_logs("app.log")
error_logs = filter_by_level(log_data, "ERROR")
request_ids = extract_request_ids(error_logs)
full_traces = log_data.filter(lambda x: x.request_id in request_ids)

# 生成时间线
timeline = generate_timeline(full_traces)
print(timeline.to_string())

日志埋点的黄金法则

预防胜于治疗。在写代码时,就应该考虑调试需求:

java
// ❌ 差的日志:信息不足
log.error("Request failed");

// ❌ 中等的日志:有信息但格式混乱
log.error("Request to " + url + " failed with status " + status + 
          " and error " + error.getMessage());

// ✅ 好的日志:结构化、有关联ID、有上下文
log.error("Downstream API call failed", 
    KeyValue.of("request_id", requestId),
    KeyValue.of("upstream_service", "payment-service"),
    KeyValue.of("upstream_url", "/api/v1/pay"),
    KeyValue.of("http_status", 500),
    KeyValue.of("error_code", "PAYMENT_TIMEOUT"),
    KeyValue.of("duration_ms", duration),
    KeyValue.of("retry_count", retryCount),
    error // Throwable不要省略
);

3.2 监控指标:从表象到本质

监控的四个黄金指标(USE方法 + RED方法)

USE方法(适合资源类指标):

  • Utilization(利用率):资源被使用的程度
  • Saturation(饱和度):资源排队/等待的程度
  • Errors(错误):错误发生的频率

RED方法(适合服务类指标):

  • Rate(请求率):每秒请求数
  • Errors(错误率):失败请求的百分比
  • Duration(延迟):请求响应时间分布

定位问题的指标分析套路

套路一:资源瓶颈定位

markdown
1. CPU高 → 找热点代码
   - 哪个进程占用CPU高?
   - 哪个函数的CPU time最长?
   - 是User CPU还是System CPU?
   
2. 内存高 → 区分内存类型
   - RSS高:内存泄漏 or 正常缓存?
   - 堆内存:对象分配速率 vs GC频率
   - 非堆内存:Metaspace、JIT代码缓存

3. IO高 → 定位IO来源
   - Disk IO:读写比例、IOPS、吞吐量
   - Network IO:带宽使用、连接数、协议分布

套路二:延迟问题定位

markdown
延迟分析黄金公式:

总延迟 = 网络延迟 + 序列化延迟 + 计算延迟 + 排队延迟 + 资源等待延迟

排查步骤:
1. 确认是单点延迟还是全局延迟
2. 拆分延迟构成(用tracing数据)
3. 定位瓶颈在哪一层
4. 对比正常情况的延迟分布

3.3 分布式追踪:串联调用链路

在微服务架构中,一个请求可能涉及十几个服务的调用。分布式追踪是定位跨服务问题的利器。

追踪数据的分析模式

模式一:串行调用链分析

请求进入 → Service A (10ms) → Service B (50ms) → Service C (5ms)
                    ↓              ↓                  ↓
                  [OK]           [TIMEOUT]           [未调用]

结论:B服务超时导致整个调用链失败

模式二:并行调用分析

请求进入 → Service A (汇总服务)
                ↓
        ┌───────┼───────┐
        ↓       ↓       ↓
    Item Svc  User Svc  Order Svc
     20ms     30ms      25ms
        ↓       ↓       ↓
        └───────┼───────┘
                ↓
        总延迟 = max(20, 30, 25) = 30ms(最慢的那个)
        
结论:如果总延迟远超30ms,可能是汇总逻辑有问题

模式三:依赖调用分析

                            ┌──────────────┐
                            │  问题请求    │
                            │  P99=5000ms  │
                            └──────┬───────┘
                                   │
              ┌────────────────────┼────────────────────┐
              ↓                    ↓                    ↓
        ┌──────────┐        ┌──────────┐         ┌──────────┐
        │ Auth Svc  │        │ User Svc │         │ Order Svc│
        │ P99=5ms ✓ │        │P99=200ms✓│         │P99=5000ms│
        └──────────┘        └──────────┘         └────┬─────┘
                                                      │
                                             ┌────────┴────────┐
                                             ↓                 ↓
                                       ┌──────────┐      ┌──────────┐
                                       │DB Query  │      │ 3rd API  │
                                       │P99=50ms  │      │P99=4900ms│
                                       └──────────┘      └──────────┘
                                       
结论:Order Svc调用的第三方API是瓶颈

3.4 网络问题排查:从ping到tcpdump

网络问题是最容易让人抓狂的,因为它涉及太多层面。

网络排查的层层递进

层级一:连通性(能通吗?)

bash
# 基础连通性
ping -c 5 target-service

# 端口可达性
nc -zv target-service 8080

# DNS解析
nslookup target-service
dig target-service

层级二:可达性(能连上吗?)

bash
# TCP连接测试
telnet target-service 8080
nc -tv target-service 8080

# 检查路由
traceroute target-service  # Linux
tracert target-service      # Windows

层级三:性能(够快吗?)

bash
# 网络质量
ping -c 100 target-service | tail -1

# 带宽测试
iperf3 -c target-service

# 并发连接测试
wrk -t4 -c100 -d30s http://target-service/api

层级四:内容(传输正确吗?)

bash
# 抓包分析(最底层、最强大)
tcpdump -i any -w capture.pcap host target-service and port 8080

# HTTP层面抓包
tshark -i any -Y "http.request" -T fields -e http.request.uri

# 分析已捕获的pcap文件
wireshark capture.pcap

第四部分:典型问题模式与诊断路径

4.1 服务无响应

症状:请求发出去,没有响应,也没有报错。

诊断决策树

服务无响应
    │
    ├── 服务进程还在吗?
    │   │
    │   ├── 进程不存在 → 检查OOM kill、crash、部署问题
    │   │
    │   └── 进程存在但僵死 → 检查GC、线程死锁、CPU绑定
    │
    ├── 能建立连接吗?
    │   │
    │   ├── 不能 → 网络问题、防火墙、端口未监听
    │   │
    │   └── 能建立但无响应 → 队列满、线程池耗尽、慢查询
    │
    └── 连接建立后多久响应?
        │
        ├── 永远不响应 → 服务hang住、死锁、无限循环
        │
        └── 超时才响应 → 依赖服务超时(级联超时)

4.2 服务报错(4xx/5xx)

诊断决策树

服务报错
    │
    ├── 4xx错误(客户端错误)
    │   │
    │   ├── 400 Bad Request → 参数校验失败,查看请求体
    │   ├── 401 Unauthorized → 认证失败,检查token
    │   ├── 403 Forbidden → 授权失败,检查权限
    │   └── 404 Not Found → 路径错误或资源不存在
    │
    └── 5xx错误(服务端错误)
        │
        ├── 500 Internal Server Error
        │   ├── 无日志 → 异常未捕获,try-catch问题
        │   ├── 有日志 → 根据日志定位代码位置
        │   └── 偶发 → 并发问题、race condition
        │
        ├── 502 Bad Gateway(网关/代理问题)
        │   ├── 上游服务挂了?检查上游健康状态
        │   ├── 超时?检查上游响应时间
        │   └── 配置错误?检查路由规则
        │
        ├── 503 Service Unavailable
        │   ├── 服务在重启?
        │   ├── 资源耗尽?
        │   └── 熔断了?
        │
        └── 504 Gateway Timeout
            ├── 上游服务慢?
            ├── 网络问题?
            └── 超时配置过短?

4.3 性能劣化

诊断决策树

性能劣化(P99/平均延迟上升)
    │
    ├── 是新代码导致的?
    │   │
    │   ├── 是 → 代码review、新功能分析
    │   │
    │   └── 否 → 不是代码问题,是环境/流量问题
    │
    ├── 是资源瓶颈吗?
    │   │
    │   ├── CPU瓶颈 → 热点代码分析
    │   ├── 内存瓶颈 → GC问题 or 内存泄漏
    │   ├── IO瓶颈 → Disk IO or Network IO
    │   │
    │   └── 资源充足 → 不是资源问题,是逻辑问题
    │
    └── 是依赖瓶颈吗?
        │
        ├── 上游服务慢?→ Trace分析定位慢服务
        ├── 数据库慢?→ 慢查询分析
        ├── 缓存失效?→ 命中率分析
        │
        └── 依赖都正常 → 服务自身逻辑问题

第五部分:心态与习惯

5.1 调试的正确心态

心态一:问题是可以被理解的

面对神秘莫测的bug,最大的敌人是"这是玄学"的心态。如果你相信任何问题都无法被理解,你永远不会去分析它。

正确的信念:这个问题一定有原因,只是我还没找到。我需要的是更好的工具、更系统的思路,而不是运气。

心态二:证据比直觉更重要

"我觉得应该是..."是调试中最危险的一句话。

正确的做法

  • "日志显示..." + "所以我推测..."
  • "监控数据表明..." + "因此我得出..."
  • "压力测试证明..." + "说明假设成立"

心态三:不要害怕说"我不知道"

很多程序员害怕承认自己不知道问题出在哪里。这导致他们:

  • 不愿意花时间分析,匆忙重启
  • 假装知道,乱改一通
  • 错失学习机会

正确的做法

"目前我还不确定问题的根因。我有以下假设:[列出假设]。我需要做以下验证来确认:[列出验证计划]。"

5.2 调试的好习惯

习惯一:记录你的排查过程

markdown
## 问题:2024-01-15 用户支付超时

### 现象
- 用户支付时等待30秒后显示超时
- 超时后重试成功

### 排查过程
14:00 - 查看支付服务日志,发现大量 "Connection timeout"
14:15 - 检查支付服务到银行接口的网络延迟,正常(<50ms)
14:30 - 检查数据库连接池,发现连接数达到上限(100/100)
14:45 - 检查连接池使用情况,发现有慢查询占用连接超过10秒
15:00 - 分析慢查询,发现缺少索引导致全表扫描

### 根因
用户表缺少 status + create_time 联合索引

### 修复
添加索引:ALTER TABLE users ADD INDEX idx_status_created(status, create_time)

### 验证
上线后监控:连接池使用率从100%降到30%,支付成功率从95%提升到99.9%

习惯二:保留现场

遇到问题时的第一个动作不是修复,而是保留现场

bash
# 保存日志
cp /var/log/app.log /tmp/app.log.$(date +%Y%m%d%H%M%S)

# 保存内存dump(Java)
jmap -dump:format=b,file=/tmp/heap.hprof <pid>

# 保存进程状态
ps auxf > /tmp/ps auxf.$(date +%Y%m%d%H%M%S)

# 保存网络连接
netstat -anp > /tmp/netstat.$(date +%Y%m%d%H%M%S)

# 保存JVM信息
jstat -gcutil <pid> > /tmp/gc.log.$(date +%Y%m%d%H%M%S)

习惯三:事后复盘

问题解决后,花15分钟回答三个问题:

  1. 1.这次排查中,我做对了什么? → 强化好习惯
  2. 2.这次排查中,我走了什么弯路? → 避免下次重蹈覆辙
  3. 3.这个问题可以预防吗? → 改进监控/流程/代码质量

结语:从操作工到工程师

调试能力的提升,本质上是从"操作工"到"工程师"的转变。

操作工的思维:遇到问题 → 执行已知解决方案 → 问题解决/升级
工程师的思维:遇到问题 → 分析根因 → 设计解决方案 → 验证修复 → 预防同类问题

当你不再依赖"重启"来解决问题,而是能够系统性地追踪、定位、修复并预防问题,你就不再是一个"代码搬运工",而是一个真正的问题解决者。

下次当你想要输入kubectl restart的时候,试着先问自己三个问题:

  1. 1.这个问题真的被解决了吗?还是只是暂时消失?
  2. 2.我能说出问题的根因吗?
  3. 3.下次再遇到同类问题,我能不能更快定位?

如果你对任何一个问题的答案是否定的,那么——先把重启命令放下,拿起日志,开始分析

这才是真正的调试。

接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍

作者 倾颜
2026年4月10日 09:21

本文对应项目版本:v0.0.9

这半年只要聊到 MCP,讨论几乎都会很快滑向同一个方向:

  • 怎么做平台
  • 怎么做编排
  • 怎么管多个 server
  • 怎么继续接 Agent

这条路线当然成立,但它有个很容易被跳过的前置问题:

你现在的系统,真的已经准备好接住 MCP 了吗?

如果答案还是模糊的,那“先平台化”很多时候只是把问题往后推。

你会先做出一套看起来很完整的抽象,然后再回头发现,真正难的不是平台长什么样,而是更基础的三件事:

  • 现有 Runtime 能不能稳定消费 MCP Tool
  • 文件读取这种能力到底该算 Tool 还是 Resource
  • 前端能不能把这两类能力真实区分开

我这次做的,就是先不跳到“大而全”的那一步。

我没有单独起一套 MCP Runtime,也没有直接继续做 Agent,而是先做了一件更小、但我觉得更值的事:

把 MCP 当成“能力来源层”,接进现有 Skill Runtime,先证明它能在真实主链里工作。

这篇文章不会把重点放在“我接了几个 server”上,而会重点讲清楚 3 个更实际的问题:

  1. 为什么我没有先做平台化
  2. 为什么天气先走 MCP Tool,而文件读取必须升级成 MCP Resource
  3. 为什么前端从这一步开始必须正式区分 Tool card 和 Resource card

如果你也是下面这些情况,这篇会比较对路:

  • 你已经有 Tool Calling / Skill Runtime,正在想 MCP 该怎么进来
  • 你刚开始接触 MCP,想先理解它在工程里到底怎么落地
  • 你不想先看一堆概念图,而是想看一个真实项目是怎么接进来的

mcp-1.gif

说明:天气查询仍然展示为 Tool card,读取 README.md 已经展示为 Resource card,前端能直接看出两类能力的区别。

先说结论:MCP 最先改变的,通常不是架构层数,而是能力来源

如果只用一句话概括这次实践,我会写成:

接入 MCP,最先该改变的,不一定是 Runtime 形态,而往往是“能力从哪里来”。

比如在我这个项目里,本来就已经有两类能力:

  • city-weather
  • local-text-read

从模型视角看,它们只是两个可调用能力。

但从工程视角看,这两个能力其实属于完全不同的两类来源:

  • 天气更像“调用一个外部动作”
  • 文件读取更像“读取一个受控资源”

一旦开始用 MCP 接这两类能力,你很快就会遇到两个非常真实的问题:

  1. 模型看到的能力名,要不要跟着 MCP 一起改?
  2. 文件读取这种能力,到底还该不该继续伪装成 Tool?

这两个问题,远比“要不要先做平台化”更应该优先回答。

如果你第一次接触 MCP,只要先搞清 4 个词就够了

网上关于 MCP 的资料很多,但如果你现在是第一次真正在项目里接它,我建议先不要把自己扔进一整套协议细节里。

先搞懂下面 4 个词,已经足够把这篇读明白。

Host

Host 可以简单理解成:

你自己的应用里,负责连接和消费 MCP server 的那一层。

它不一定是一个巨大的平台,也不一定是一个可视化控制台。

在这个项目里,Host 做的事情很朴素:

  • 知道有哪些 MCP server
  • 什么时候拉起它们
  • 什么时候调用 Tool
  • 什么时候读取 Resource

Tool

Tool 是动作型能力。

你给它参数,它帮你执行一次动作,然后返回结果。

比如天气查询就很适合 Tool 语义:

  • 输入:城市名
  • 输出:当前天气文本

Resource

Resource 是读取型能力。

它不像 Tool 那样强调“执行一次动作”,更像是:

在一个受控边界里,读取一段已经存在的内容。

比如:

  • 读取 README.md
  • 读取 package.json
  • 读取某个受控 URI 对应的内容

stdio

stdio 可以理解成最小闭环方案。

也就是你的应用直接通过本地进程和 MCP server 通信,而不是一上来就做远程 HTTP、鉴权、编排这些更重的东西。

这次我故意先只做本地 stdio,因为它最适合先验证一件事:

MCP 这套能力来源,能不能被现有主链稳定吃进去。

而且当前这个 Host 也不是额外再造的一层平台,而是基于官方 SDK 接起来的最小消费层。

这版的 MCP SDK 接入方式

很多文章讲 MCP,会把重点放在协议概念上。

但真到接入时,更实际的问题其实是:

你准备用什么方式把 server、client 和 transport 这几层真正落下来?

我这版没有把 SDK 当成一个“顺手一提的依赖”,而是把它放在了 Host 基础层最合适的位置上。

具体来说,当前这条链路是很清楚的:

  • server 侧用官方 SDK 的 McpServerStdioServerTransport 暴露能力
  • client 侧用官方 SDK 的 ClientStdioClientTransport 发起连接
  • 中间再用一个 MCPClientManagerserverId 复用 client

也就是说,SDK 在这里不是直接跑到业务层里到处被调用,而是被收在一条比较干净的基础层里:

  • server 负责声明 MCP 能力
  • client 负责连接、初始化、调用和错误收束
  • manager 负责复用 client
  • adapter 再往上把 MCP 结果翻译成当前 Runtime 认识的 Tool / Resource 结构

这段代码解决的问题是:把这版 MCP 接入里最关键的 server / client / transport 落位方式讲清楚。

import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'

const client = new Client(MCP_CLIENT_INFO, {
    capabilities: MCP_CLIENT_CAPABILITIES,
})

const server = new McpServer({
    name: 'weather-server',
    version: '0.0.9',
})

这段代码本身不复杂,但它背后有几个很实际的工程判断:

  • client、transport、server 都直接走官方公开子路径,后续升级时更容易对照官方文档
  • MCPClient 只处理单个 server 的连接、调用和超时,不顺手掺业务语义
  • MCPClientManager 只负责按 serverId 复用 client,避免请求一多就重复拉进程
  • 真正跟业务相关的参数映射、错误翻译和结果规整,继续留在 adapter 层

这样做的好处,不是“更标准”这么抽象,而是非常实际:

  • 你能很清楚地知道 server 写在哪一层
  • 你能很清楚地知道 client 负责什么、不负责什么
  • 你后面要接第二个、第三个 MCP server 时,不需要再把主链扒开重写

同时我也把 MCP 代码明确收在服务端 / Node runtime,不往浏览器侧扩。

对这版来说,SDK 这一块真正想解决的不是“怎么秀一套接入技巧”,而是:

先把 server、client、transport 这条基础层收稳,再往上谈平台化、编排和 Agent。

为什么我没有先单独起一套 MCP Runtime

这是这篇最核心的取舍。

很多人一看到 MCP,第一反应是对的:

“这个东西以后肯定会越来越多,那是不是应该先抽一层独立 Runtime?”

问题是,这个判断太早了。

因为如果你现在的项目里,连下面这些问题都还没证明:

  • 现有 Tool Runtime 能不能稳定承接 MCP Tool
  • 现有消息协议能不能承接 MCP Resource
  • 现有前端能不能真实表达 Tool / Resource 差异
  • 现有 Skill 边界会不会被 MCP 污染

那你先做出一层新 Runtime,本质上还是在“想象未来需求”。

而我这次更想先证明“今天真实会发生什么”。

所以我有意压住了这几个方向:

  • 不做远程 HTTP MCP
  • 不做多 server 编排
  • 不做 Resource Picker
  • 不做 server 状态面板
  • 不把 utility-skill 一起迁进去
  • 不提前进入 Agent

这不是说这些不重要,而是因为这一版更值得验证的,是一个更朴素的问题:

现有系统能不能在不推翻主链的前提下,先把 MCP Tool 和 MCP Resource 接进来。

如果答案是可以,那后面的平台化才是顺势而为。

如果答案是还不行,那先做平台化反而会把问题藏起来。

这次最重要的策略:能力名不变,只替换底层来源

这是我这次最想强调的一点。

接 MCP 的时候,我没有让模型开始学习一堆新名字。

我没有把:

  • city-weather
  • local-text-read

改成:

  • get_weather
  • project-resource-read
  • resources/read

相反,我刻意让模型继续看到原来的能力名。

也就是说,从模型视角看,一切几乎没变:

  • 它还是在调用 city-weather
  • 它还是在调用 local-text-read

但运行时已经知道,底层来源变了:

  • city-weather 实际走 weather-server.get_weather
  • local-text-read 实际走 project-files-server.resources/read

为什么这个取舍重要?

因为它能把变化压缩在最合适的一层。

这样一来:

  • 模型心智没被打乱
  • Skill 边界没被打乱
  • 真正发生变化的,是能力来源

这会让你更容易验证问题到底出在哪里。

否则你同一版里同时改:

  • 模型看到的能力名
  • Skill 分层
  • 底层能力来源
  • 前端展示方式

最后哪怕效果不好,你也很难判断到底是哪一层出了问题。

天气为什么先走 MCP Tool

如果你想先验证 MCP Tool 主链,天气是一个特别好的切入点。

原因不是它业务价值有多高,而是它特别“像 Tool”:

  • 输入参数简单
  • 调用动作明确
  • 返回结果直观
  • 成功失败都容易观察

所以我先做了一个很小的 weather-server,只暴露一个 Tool:

  • get_weather

然后在项目里继续保留模型熟悉的名字:

  • city-weather

中间用一层 adapter 做映射。

这段代码解决的问题是:

模型侧保留原有能力名,运行时把它稳定映射到底层 MCP Tool,同时把结果整理成当前主链已经认识的结构。

const WEATHER_SERVER_ID = 'weather-server'
const WEATHER_TOOL_NAME = 'get_weather'

export const weatherToolAdapter: MCPToolAdapter<WeatherToolAdapterInput> = {
    async call(input): Promise<MCPToolAdapterResult> {
        const response = await mcpClientManager.callTool(WEATHER_SERVER_ID, {
            arguments: { city: input.city },
            name: WEATHER_TOOL_NAME,
        })

        const outputText = extractToolText(response.result)

        if (response.result.isError) {
            throw new MCPHostError('REQUEST_FAILED', outputText || '天气 MCP Tool 调用失败。')
        }

        return {
            action: 'current',
            inputText: `city=${input.city}`,
            outputText,
            serverId: WEATHER_SERVER_ID,
            source: 'mcp',
            title: 'city-weather',
            toolName: 'city-weather',
        }
    },
}

这里的重点不是“代码能调用成功”,而是 adapter 做了 4 件很关键的事:

  • 参数映射
  • 错误翻译
  • 结果标准化
  • 来源信息补齐

这意味着 MCP 原始结构并没有直接漏进主运行时。

主链只知道:

  • 这是一次 city-weather
  • 来源是 MCP
  • 来自 weather-server
  • 已经有了标准化结果

这就是我说的“先改变能力来源,而不是先改 Runtime 形态”。

mcp-weather.png

说明:展示 city-weather 在用户视角下仍然是原来的能力,但卡片上已经能看到 来源:MCPweather-server

文件读取为什么不能继续伪装成 Tool

天气这条线解决的是“Tool 怎么接进来”。

文件读取解决的是另一个更重要的问题:

有些能力本质上就不该再被当成 Tool。

以前很多项目做本地文件读取时,会顺手做一个 Tool:

  • 输入文件名
  • 返回文件内容

这当然能跑,但一旦你开始用 MCP 去理解它,就会发现它的语义其实不太像 Tool,而更像 Resource。

为什么?

因为文件读取更像是在做下面这些事情:

  • 读取某个 URI 对应的内容
  • 只读,不执行副作用
  • 有明确边界
  • 适合展示预览

这其实正是 Resource 的典型场景。

所以这次我没有继续让文件读取伪装成“另一个 Tool”。

我做的是:

  • 模型侧继续保留 local-text-read
  • 底层把它转成 project://README.md 这样的 Resource URI
  • 通过 project-files-server.resources/read 去读取

而且原来的安全边界全部保留:

  • 只允许根目录直接文本文件
  • 不允许子目录
  • 不允许绝对路径
  • 不允许 ../
  • 非文本文件拒绝

这段代码解决的问题是:

把文件读取从“本地直接访问”升级成“受控 Resource 读取”,同时把预览信息整理成前端能直接消费的结构。

export const projectFileResourceAdapter: MCPResourceAdapter<ProjectFileResourceAdapterInput> = {
    async read(input): Promise<MCPResourceAdapterResult> {
        const safeFilename = assertSafeRootFilename(input.filename)
        const uri = createProjectResourceUri(safeFilename)
        const response = await mcpClientManager.readResource(PROJECT_FILES_SERVER_ID, { uri })
        const textContent = extractTextContent(response.result)

        if (!textContent) {
            throw new MCPHostError('REQUEST_FAILED', '项目文件 MCP Resource 没有返回可用文本内容。')
        }

        return {
            content: textContent.text,
            contentPreview: createProjectResourcePreview(textContent.text),
            previewChars: MAX_PROJECT_RESOURCE_PREVIEW_CHARS,
            resourceName: safeFilename,
            serverId: PROJECT_FILES_SERVER_ID,
            status: 'completed',
            uri,
        }
    },
}

这段代码带来的变化,不只是“读文件换了个通道”,而是整个系统开始正式承认:

  • 天气是 Tool
  • 文件读取是 Resource

这两类能力不该继续被混成一类。

这也是为什么我会说,文件读取这一步其实比天气更关键。

因为它逼着整个系统第一次认真区分:

什么是动作型能力,什么是读取型能力。

前端为什么必须开始区分 Tool card 和 Resource card

很多后端接入类文章,写到这里就结束了。

但我觉得 MCP 真正开始成立,恰恰是在前端。

因为如果前端还是把所有东西都塞回 Tool card,那 Resource 在产品层面其实根本没有被表达出来。

用户只会感觉:

“哦,又多了一个工具调用卡片。”

但他不会理解系统已经多了一种新的能力类型。

所以这次我没有只改后端,也同步改了流式协议。

这段代码解决的问题是:

给 Resource 一套独立的流式生命周期,而不是继续借 Tool 协议蹭展示。

export interface ResourceStartChunk {
    type: 'resource-start'
    partId: string
    resourceName: string
    uri: string
    serverId: string
}

export interface ResourceEndChunk {
    type: 'resource-end'
    partId: string
    resourceName: string
    uri: string
    serverId: string
    contentPreview?: string
    isTruncated?: boolean
    previewChars?: number
}

export interface ResourceErrorChunk {
    type: 'resource-error'
    partId: string
    resourceName: string
    uri: string
    serverId: string
    message: string
}

这三个事件看起来只是多了几个类型,但它们的意义很大:

  • Resource 有自己的开始、完成、失败
  • 它不是 Tool 的一种特殊状态
  • 它应该以另一种 part 进入消息模型

前端接着也按这个语义去消费它。

这段代码解决的问题是:

把 Resource 当成正式消息 part 处理,而不是继续硬塞进 Tool part。

case 'resource-start': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        appendPart(current, messageId, createResourcePart(chunk.partId, chunk.resourceName, chunk.uri, chunk.serverId))
    )
    return
}

case 'resource-end': {
    const messageId = activeStreamRef.current.messageId

    if (!messageId) {
        return
    }

    updateMessages(current =>
        updateResourcePart(current, messageId, chunk.partId, part => ({
            ...part,
            status: 'completed',
            contentPreview: chunk.contentPreview,
            isTruncated: chunk.isTruncated,
            previewChars: chunk.previewChars,
        }))
    )
    return
}

这一步带来的直接效果是:

  • 天气继续显示 Tool card
  • 文件读取开始显示 Resource card
  • 用户第一次能直观看到两类能力的边界

这不是简单的 UI 小修,而是协议层、消息模型层、产品表达层一起升级。

mcp-resource.png

说明:展示资源名称、URI、serverId、状态、内容预览,以及它和 Tool card 的区别。

为什么是 reader-skill 在承接 MCP,而不是别的层

如果你已经有 Tool Runtime,又想开始接 MCP,很容易冒出一个问题:

“要不要新起一个 mcp-skill?”

我这次没有这么做。

原因很简单:

这版里需要接入 MCP 的两类能力,本来就属于 reader-skill 的边界:

  • 天气查询
  • 文件读取

它们的共同点不是“都是 MCP”,而是“都是外部上下文获取”。

也就是说,真正稳定的边界不是 MCP,而是 reader-skill 本身。

这也是我为什么一直觉得,Skill / MCP / Agent 这几层不要轻易混:

  • Tool 是原子能力
  • Skill 是能力模式
  • MCP 是能力来源通道
  • Agent 才是计划与继续决策

如果一接 MCP 就先做一个 mcp-skill,很容易把“能力来源”错误地提升成“能力模式”。

但实际上,在这次实践里更自然的做法是:

  • 继续保留原有 Skill 边界
  • 只替换它底层消耗的能力来源

这样好处很明显:

  • 普通聊天主链不被污染
  • utility-skill 不被连带改造
  • reader-skill 反而变得更有解释力

因为从这一步开始,reader-skill 不再只是“一个能读文件、查天气的 Skill”,而是:

第一个正式承接 MCP 能力来源的 Skill。

这次实践真正证明了什么

如果把“项目支持 MCP 了”这种大而泛的说法放一边,这次实践真正证明的其实是下面几件更具体的事。

1. 现有 Runtime 不重做,也能接入 MCP

这很关键。

因为它说明现有主链不是必须推翻重来,MCP 可以先作为能力来源层进入现有系统,而且 SDK 的落位方式也可以先收稳。

2. 能力名不变,只换底层来源,是非常有效的过渡策略

这能最大程度保持模型心智稳定,也能更清楚地定位问题发生在哪一层。

3. 文件读取一旦升级成 Resource,整个系统的分层会明显变清楚

这一步不只是“换个 API”,而是在认真区分:

  • 什么是 Tool
  • 什么是 Resource

4. 前端是否区分 Tool / Resource,决定了这次接入是不是“真的成立”

如果前端不区分,MCP Resource 在产品层面就还是半成品。

5. 现在还没必要急着做 Agent

因为当前更值得先收稳的是:

  • MCP Tool / Resource 的真实接入边界
  • reader-skill 的承接方式
  • 协议和前端表达是不是已经站住

如果这些问题都还没收住,就急着往 Agent 走,很容易把“能力接入问题”和“任务调度问题”混在一起。

最后

如果你现在也在做 MCP 接入,我会很推荐先问自己一个问题:

我现在真正缺的,是一套平台,还是一条能被主链真实验证的接入路径?

这两个答案,最后导向的实现方式会完全不同。

对这个项目来说,这次更合适的答案是后者。

所以我没有先做平台化,而是先把 MCP 放进一个真实会被用到的地方:

  • 天气走 MCP Tool
  • 文件读取走 MCP Resource
  • reader-skill 成为第一层承载层
  • 前端正式区分 Tool card 和 Resource card

这条路听起来不激进,但它非常扎实。

因为从这一版开始,MCP 不再只是“以后可能会接”的方向,而是已经进入当前系统主链、真正开始工作的能力来源层。

项目地址

GitHub: github.com/HWYD/ai-min…

如果这篇文章或这个项目对你有帮助,欢迎到仓库里看看,也欢迎顺手点个 Star。
后面我会继续沿着 Skill -> MCP -> Agent 这条线,把这个 Runtime Skeleton 一版一版往前推进。

Vite8 怎么配置生产环境移除 log

作者 z2z2
2026年4月10日 09:16

转载请注明出处: 原文链接:Vite8 怎么配置生产环境移除log

配置 build 时移除 log

vite.config.ts 中添加以下配置,就可以实现了。

build: {
  rolldownOptions: {
    output: {
      minify: {
        compress: {
          dropConsole: true,
        },
      },
    },
  },
},

怎么样在不同的环境分别控制移除log呢?

答案就是:使用环境变量

  1. 创建.evn文件,添加一个环境变量
# true: 移除log
VITE_DROP_CONSOLE=true

上面的环境变量要分别配置到不同的环境上,.env文件中仅仅是一个默认值。

  1. vite.config.ts 中添加以下配置
import { defineConfig, loadEnv } from "vite";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd());

  return {
    build: {
      rolldownOptions: {
        output: {
          minify: {
            compress: {
              dropConsole: env.VITE_DROP_CONSOLE === "true",
            },
          },
        },
      },
    },
  };
});

3.响应式系统基础:从发布订阅模式的角度理解 Vue2 的数据响应式原理

作者 Cobyte
2026年4月10日 09:04

前言

关于 Vue2 和 Vue3 的数据响应式原理,相信 Vue 技术栈的同学或多或少都了解过,甚至在简历上写很熟悉,而且我们在第一篇中也已经基本实现过了,但大家是否真的彻底掌握了呢?

正如我们前面所说的那样,Vue2、Vue3、SolidJS、Mobx 它们的数据响应式基本原理都是一样的,具体区别只是设计理念和实现方式不一样。按以前读书时代考试做题一样,它们是同一类型的题目,也就是基于依赖收集和触发的运行时的数据响应式,如果说你只会解答其中一道题,其他的题却不会解答,则说明你并没有真正彻底掌握这一类题。

同样地基于依赖追踪和触发的响应式系统都是通过发布订阅模式进行实现的,那么你知道 Vue 的数据响应式原理中是如何运用发布订阅模式的吗?

所以我们在本篇当中将从发布订阅模式的角度来理解 Vue 的数据响应式原理,彻底掌握数据响应式的基本原理,同时也巩固我们在上一篇中所说的发布订阅模式。我们在上一篇中学习了发布订阅模式,我们都是基于一些 demo 的例子去理解,本篇则真正的把发布订阅模式在实际项目的进行运用。

温馨提示,阅读本本之前最好先阅读前一篇文章,对发布订阅模式有一定的理解

发布订阅模式原理回顾

我们在上一篇中最后是通过 Object.defineProperty 方法对公众号对象 weChatOfficialAccountarticle 属性进行劫持监听,然后在 getter 的时候进行订阅,在 setter 的时候进行发布。

代码如下:

// 定义公众号
const weChatOfficialAccount = {
    // 订阅公众号的人的记录列表
    subscribers: [],
    // 文章内容
    article: '',
    // 添加订阅者
    addDep(fn) {
        // 把订阅者添加进记录列表
        this.subscribers.push(fn) 
    },
    // 广播信息
    notify(title) {
        // 发布信息时就是把记录列表中的订阅者全部通知一次
        this.subscribers.forEach(fn => fn(this.article))
    },
    // 取消订阅
    remove(fn) { 
        // 找到需要删除的订阅者
        const index = this.subscribers.indexOf(fn)
        // 删除订阅者
        this.subscribers.splice(index, 1 )
    }
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) weChatOfficialAccount.addDep(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            weChatOfficialAccount.notify()
        }
    })
}

我们可以看到 weChatOfficialAccount 对象上有很多属于发布订阅模式中的功能,如果说还有其他对象也需要实现这样的功能,那么也要实现一遍这些功能,很明显这样是不可接受的,我们可以通过上一篇中实现的消息代理来代替 weChatOfficialAccount 对象中的发布订阅模式的功能。

首先我们对消息代理的实现做如下修改:

class Dep {
  constructor() {
    // 订阅者存储中心
    this.subs = []
  }
  // 添加订阅者
  addSub(sub) {
    this.subs.push(sub)
  }
  // 通知订阅者
  notify() {
    this.subs.forEach(sub => sub())
  }
}

如果熟悉 Vue2 数据响应式原理的同学对上面的代码肯定很熟悉,这个就是 Vue2 源码中的 Dep 类的简易实现,所以 Vue2 源码中的 Dep 其实就是一个事件总线或者叫消息代理,但它又不仅仅是消息代理,在某些时刻它同时又是一个订阅者,这个情况就是我们在上篇当中所说的一个对象既可以是发布者也可以是订阅者,而在 Vue2 源码中 Dep 类既是消息代理中心又是订阅者,具体情况我们将下文中进行详细讲解。

接下来我们继续做如下修改:

const weChatOfficialAccount = {
    // 消息代理对象
    __ob__: new Dep(),
    // 文章内容
    article: '', 
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者就进行订阅操作
            if (subscriber) dep.addSub(subscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们在公众号对象中通过 __ob__ 属性来存储消息代理对象,这样公众号对象原本属于发布订阅的功能就通过 Dep 类来实现了,这样代码的功能职责就梳理得十分清晰了,也符合代码整洁之道。

我们对修改后的代码进行测试:

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.article}`)
}
// 读取一次,触发 getter 进行订阅
weChatOfficialAccount.article
// 设置为 null 防止重复订阅
subscriber = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

上述测试代码的测试结果将会打印:

收到的公众号文章:通过 Object.defineProperty 方法实现订阅发布模式

我们通过 Object.defineProperty 方法实现对 weChatOfficialAccount 对象的属性 article 进行监听实现发布订阅功能,然后订阅的时候需要读取一下,触发 getter 进行订阅,这个行为在上述例子中比较奇怪,我们把它改成我们容易理解的例子。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-9" />
    <title></title>
  </head>
  <body>
    <div id='content'></div>
    <script>
        const weChatOfficialAccount = {
            // 消息代理对象
            __ob__: new Dep(),
            // 文章内容
            article: '文章内容', 
        }
        // 省略 ...
        // 订阅者小明
        let subscriber = () => {
            const el = document.getElementById('content')
            el.textContent = `小明收到的公众号文章:${weChatOfficialAccount.article}`
        }
        // 初始化
        subscriber()
        subscriber = null
    </script>
  <body>
</html>

我们改成我们 web 应用程序中的例子就比较容易理解了。所谓订阅者小明,就是一个 HTML 更新函数,在初始化执行 subscriber() 函数的时候,会读取公众号对象 weChatOfficialAccountarticle 属性的值,这样就会触发 getter 函数进行订阅,在后续当公众号对象 weChatOfficialAccountarticle 属性值发生变化的时候,就会触发 setter 进行发布,也就是重新执行订阅者函数,然后网页内容发生变化。这个也是 Vue2 中的数据响应式的基本原理。

在上述例子中我们只对其中一个属性进行监听,但实际情况很有可能有其他订阅者对其他属性的进行引用。

// 订阅者郭靖
let guojingSubscriber = () => {
    console.log(`郭靖收到的公众号文章作者:${weChatOfficialAccount.author}`)
} 
// 订阅者杨过
let yangguoSubscriber = () => {
    console.log(`杨过收到的公众号发布时间:${weChatOfficialAccount.date}`)
}

然后我们需要对公众号对象进行修改:

const weChatOfficialAccount = {
    // 事件总线对象
    __ob__: new Dep(),
    // 文章内容
    article: '文章内容',
+    author: '作者',
+    date: '日期'
}
defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
+ defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
+ defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)

同时需要对 getter 中的添加订阅者部分进行修改,为了精准添加对应的订阅者,我们需要判断对应的属性:

function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 为了精准添加对应属性的订阅者,我们需要判断对应的属性
+            if (subscriber && key === 'article') dep.addSub(subscriber)
+            if (guojingSubscriber && key === 'author') dep.addSub(guojingSubscriber)
+            if (yangguoSubscriber && key === 'date') dep.addSub(yangguoSubscriber)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

我们上述代码中为了精准添加对应属性的订阅者,我们需要在 getter 中判断对应的属性,在功能简单的情况下可以,如果功能复杂,对象的属性庞大的情况下,这样肯定是不能接受的。

又因为在触发 getter 的时候,只会是在某个订阅者函数在执行的时候,也就是说在 getter 被触发的时候,这个时候的订阅者是确定的,所以我们可以采用 中间变量 形式来解决这个问题。我们设置一个全局变量 activeEffect,也就是所谓中间变量,然后在初始化执行订阅者函数之前把需要执行的订阅者函数赋值给 activeEffect,然后在 getter 里面就可以把中间变量 activeEffect 通过消息代理对象添加到订阅者记录里面了,然后在执行完该订阅者函数之后则需要把中间变量 activeEffect 设置为 null,防止重复添加。

代码修改如下:

+ // 订阅者中间变量
+ let activeEffect
function defineReactive(data, key, val) {
    // 获取消息代理对象
    const dep = weChatOfficialAccount.__ob__
    Object.defineProperty(data, key, {
        get() {
+            // 存在订阅者中间变量就进行订阅者添加
+            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}
+ // 初始化订阅者
+ activeEffect = subscriber
+ subscriber()
+ activeEffect = null
+ activeEffect = guojingSubscriber
+ guojingSubscriber()
+ activeEffect = null
+ activeEffect = yangguoSubscriber
+ yangguoSubscriber()
+ activeEffect = null

接着我们进行测试:

// 公众号发布文章
weChatOfficialAccount.article = '通过 Object.defineProperty 方法实现订阅发布模式'

打印结果如下:

C01.png

我们就发现我们虽然只对 article 属性进行赋值,但也触发了其他属性的订阅者的执行。那么我们就需要对属性与订阅者之间进行准确关联。那么如何进行准确关联呢?我们通过第一篇文章可以知道,在通过 Object.defineProperty 对每一个属性进行劫持监听的时候,通过闭包的形式把属性值缓存下来的,所以每一个属性的消息代理也放在闭包函数 defineReactive 中。

// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
+    // 通过闭包把每一个属性的消息代理进行缓存
+    const dep = new Dep()
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect) dep.addSub(activeEffect)
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

这时我们再进行测试的时候,就可以精准触发订阅者了。

我们上面是通过手动调用 defineReactive 函数进行对象的属性劫持的,我们可以通过获取所有对象的属性然后遍历调用 defineReactive 函数进行对象的属性劫持,同时把这个功能封装成一个工具函数 observe。我们在第一篇中也实现过的了,下面我们来重新实现一下:

function observe (data) {
    Object.keys(data).forEach(key => {
        defineReactive(data, key, data[key]) 
    })
}

这样我们就可以通过 observe 函数来定义一个响应式对象了。

const weChatOfficialAccount = {
    // 文章内容
    article: '文章内容',
    author: '作者',
    date: '日期'
}
- defineReactive(weChatOfficialAccount, 'article', weChatOfficialAccount.article)
- defineReactive(weChatOfficialAccount, 'author', weChatOfficialAccount.author)
- defineReactive(weChatOfficialAccount, 'date', weChatOfficialAccount.date)
+ observe(weChatOfficialAccount)

至此我们便通过发布订阅模式初步实现了 Vue2 的响应式原理。创建一个对象,通过 observe 工具函数遍历此对象所有的 property,并使用 Object.defineProperty 把这些 property 全部转为 getter/setter,然后在 getter 的时候进行依赖收集,在 setter 的时候进行依赖触发。

从发布订阅模式的角度来说就是每一个对象的 property 都是发布者,然后它的消息代理则通过闭包的形式跟每一个 property 的值一起缓存在 defineReactive 闭包函数创建独立空间中,它们是多对多的关系。

小结

在我们一般的发布订阅模式(也叫观察者模式)中,发布者或者被观察者是很明确的,是一个具体的对象,但正如我们前一篇文章所说的那样,发布订阅模式是没有标准范式的,设计模式也是,辨别一种模式不能通过代码结构,而是代码意图。而在我们上述实现的 Vue2 响应式原理的过程中,我们发现其实每一个对象属性(property)都是一个发布者或者叫被观察者,它的发布者功能则通过消息代理中介进行实现,而这个消息代理对象则通过闭包的形式跟每个属性值一起缓存在闭包当中。我们又可以发现所谓发布订阅模式的触发条件也不是唯一的,我们一般的描述定义是,当一个对象发现变化的时候才去触发所有依赖它的订阅者,其实不然,发布订阅模式的触发条件可以是状态的变化、某个操作的变化、甚至是发布订阅者的通知也可以触发另外一个发布者进行发布操作。如果有在 Vue 中使用过事件总线的同学会很清楚,我们在组件中触发通知(emit)订阅者操作的时候并不一定是组件属性发生了变化,而有可能是某个方法触发了通知(emit)订阅者操作。

对数组进行响应式的处理

可以通过 Object.defineProperty 对数组进行监听,但监听不了数组自身的原型链方法,而 pushpopshiftunshiftsplicesortreverse 对数组进行操作是会改变数组的数据结构的,从发布订阅模式的角度来说数据发生变化后我们需要通知该数组对象的所有订阅者。为了实现这需求我们需要劫持数组的操作方法,即在对数组进行 push 等操作的时候我们能监听到。实现方案就是对数组的原型进行重写,重写的方法就是覆盖数组数组对象上的原型对象 __proto__。我们在第一篇当中是通过粗暴的直接覆盖的方式,但那样会把原来的一些数组方法也覆盖掉了,那样是不可取。

我们可以通过获取数组原型上的对象,然后只修改需要修改的方法即可。我们对 observe 方法修改如下:

function observe (data) {
+    // 如果是数组则重写数组上的原型
+    if (Array.isArray(data)) {
+        // 获取数组原型
+        const arrayProto = Array.prototype
+        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+        const arrayMethods = Object.create(arrayProto)
+        // 修改 push 方法
+        arrayMethods['push'] = function (...args) {
+            // 获取原始方法
+            const original = arrayProto['push']
+            // 执行原始方法
+            const result = original.apply(this, args)
+            return result
+        }
+        // 覆盖原型对象
+        data.__proto__ = arrayMethods
+    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
+    }
}

上述代码我们通过 Object.create 创建一个原型为 arrayProto 的空对象:arrayMethods。然后给空对象设置 push 属性值为一个函数,最终把 arrayMethods 赋值给 data__proto__。这里就涉及到了一个 JavaScript 原型链的基础知识,当我们获取一个对象的属性值的时候,我们优先从该对象的自身属性上去获取,如果找不到则沿着该对象的 __proto__ 属性上的对象上的属性去查找,如果还找不到,则继续沿着 __proto__ 上的对象去查找。

我们经过上面的代码设置之后,我们通过 observe 设置一个数组,那么这个数组的原型对象则变为了arrayMethods,当执行该数组的 push 方法,根据原型链的规则,它会先执行 arrayMethods 对象上的 push 方法,这样我们就可以对该数组的 push 方法进行了监听,我们最终还是通过原本数组上 push 方法进行操作,但我们可以捕捉到了 push 的动作,这样我们就可以在 push 操作之后,进行通知所有该数组上的订阅者了。

我们从前面的发布订阅模式的知识可以知道,一个发布者对象上需要有一个消息代理对象,所以我们需要继续迭代我们的代码:

function observe (data) {
+    // 不存在消息代理则设置消息代理对象
+    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
+            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+            data.__ob__.notify()
             // 同时对新添加的数据也进行响应式化
+             for (let i = 0, l = args.length; i < l; i++) {
+              observe(args[i])
+             }
        }
        // 覆盖原型对象
        data.__proto__ = arrayMethods
    } else {
        Object.keys(data).forEach(key => {
          // 如果属性不是 __ob__ 则进行监听
          if (key !== '__ob__')  defineReactive(data, key) 
        })
    }
}

那么我们这个发布者对象的订阅者在哪里进行添加呢,从数据响应式的角度就是这个响应式对象的依赖在哪里收集呢?

其实不管是对象还是数组的订阅者都是在 getter 中进行添加的。
例如:{ list: [1,2,3,4] }
你要获取到 list 数组的内容,首先是通过 list 这个 property 进行获取的,所以当通过 list 这个 property 进行获取数组内容的时候,就触发了 list 这个 property 的 getter。

所以我们需要对 defineReactive 函数进行修改:

function defineReactive(data, key) {
    let val = data[key]
    // 获取消息代理对象
    const dep = new Dep()
+    // 对获取到的属性值进行递归 observe 监听
+    const childOb = observe(val)
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
+                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
+                if (childOb) childOb.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
            val = newVal
            // 通知所有的订阅者
            dep.notify()
        }
    })
}

在 getter 中会进行 property 的订阅者添加,收集到的订阅者保存在对应 property 的消息代理对象中,同时也会判断,property 的值如果是一个对象,还会对这个对象进行订阅者添加,收集到的订阅者还会保存到这个对象的消息代理对象上。

所以我们还需要对 observe 函数进行修改:

+ // 判断是否是对象
+ function isObject(obj) {
+     return obj !== null && typeof obj === 'object'
+ }
function observe (data) {
+    // 不是对象则直接返回
+    if (!isObject(data)) return 
    
    // 省略 ...
    
+    // 返回消息代理对象
+    return data.__ob__
}

至此我们对数组的响应式也实现了,接下来就是进行测试:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}

observe(weChatOfficialAccount)

// 订阅者小明
let subscriber = () => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
}

// 初始化订阅者
activeEffect = subscriber
subscriber()
activeEffect = null
// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

我们可以看到正确打印了结果,也就是我们也实现了对数组的响应式。

通过重构实现 Observer 类

我们上述函数 observe 的实现其实职责是很不清晰的,也不利于后续的维护,所以我们需要对它进行重构。软件应该是“自描述”的,代码除了给机器看之外,也要给人看。我们希望写的代码更易读,让代码可以更好地表达自己的意图。

这里我们涉及到一些开发技巧,我们可以先实现具体的功能,然后再重构,在重构的时候通过封装成抽象的类或者其他函数,让代码可以更好地表达自己的意图。那么 observe 函数中可以将对数组响应式的处理,还有对对象属性循环劫持分别封装成不同的函数,然后通过函数名称可以让我们的代码意图更明显。

我们对 observe 函数进行重构,代码如下:

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return 
    // 不存在消息代理则设置消息代理对象
    if (!data.__ob__) data.__ob__ = new Dep()
    // 如果是数组则重写数组上的原型
    if (Array.isArray(data)) {
+        protoAugment(data)
    } else {
+        walk(data)
    }
    // 返回消息代理对象
    return data.__ob__
}
+ // 对数组进行响应式处理
+ function protoAugment(target) {
+    // 获取数组原型
+    const arrayProto = Array.prototype
+    // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+    const arrayMethods = Object.create(arrayProto)
+    // 修改 push 方法
+    arrayMethods['push'] = function (...args) {
+        // 获取原始方法
+        const original = arrayProto['push']
+        // 执行原始方法
+        const result = original.apply(this, args)
+        // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+        target.__ob__.notify()
        // 同时对新添加的数据也进行响应式化
        observeArray(args)
+    }
+    // 覆盖原型对象
+    target.__proto__ = arrayMethods
+ }
+ // 对对象进行响应式处理
+ function walk(obj) {
+    const keys = Object.keys(obj)
+    keys.forEach(key => {
+        // 如果属性不是 __ob__ 则进行监听
+        if (key !== '__ob__')  defineReactive(obj, key) 
+    })
+ }
 // 对数组的每一项元素都进行响应式处理
 function observeArray (items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
 }

经过我们上面对不同的功能代码进行重构后,我们就可以通过函数名称很容易理解代码的意图了。但我们上面的功能函数还是十分的分散,而它们都是同一种功能类型的函数,都是实现对象响应式的功能函数,所以我们可以通过 OOP 的思想把响应式数据和操作封装到一个类里面,这个类我们把它命名为 Observer

Observer 类的代码实现如下:

class Observer {
    constructor(value) {
        this.value = value
        this.dep = new Dep()
        this.value.__ob__ = this
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
            this.protoAugment(value)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {
            // 对对象进行响应式处理
            this.walk(value)
        }
    }
    // 进行原型重写
    protoAugment(target) {
        // 获取数组原型
        const arrayProto = Array.prototype
        // 通过 Object.create 创建一个原型为 arrayProto 的空对象
        const arrayMethods = Object.create(arrayProto)
        // 修改 push 方法
        arrayMethods['push'] = function (...args) {
            const ob = this.__ob__
            // 获取原始方法
            const original = arrayProto['push']
            // 执行原始方法
            const result = original.apply(this, args)
            // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
            ob.dep.notify()
            // 同时对新添加的数据也进行响应式化
            ob.observeArray(args)
        }
        // 覆盖原型对象
        target.__proto__ = arrayMethods
    }

    // 对对象进行响应式处理
    walk(obj) {
        const keys = Object.keys(obj)
        keys.forEach(key => {
            // 如果属性不是 __ob__ 则进行监听
            if (key !== '__ob__')  defineReactive(obj, key) 
        })
    }
    // 对数组的每一项元素都进行响应式处理
    observeArray (items) {
        for (let i = 0, l = items.length; i < l; i++) {
            observe(items[i])
        }
    }
}

function observe (data) {
    // 不是对象则直接返回
    if (!isObject(data)) return
    const ob = new Observer(data)
    // 返回 Observer 实例对象
    return ob
}

defineReactive 函数也需要进行以下修改:

function defineReactive(data, key) {
// ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (activeEffect){
                dep.addSub(activeEffect)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(activeEffect) 
            }
            return val
        },
        set(newVal) {
// ...
        }
    })
}

我们修改后进行重新测试也正常打印了结果:

C03.png

我们还可以进行一下性能优化,我们上述代码 protoAugment 函数部分,我们创建了数组原型对象的变量,而这些变量其实是不会变化,我们可以把它们的声明移到 protoAugment 函数外面,这样每一次调用 protoAugment 函数就不会重复重新创建这些变量了。

+ // 获取数组原型
+ const arrayProto = Array.prototype
+ // 通过 Object.create 创建一个原型为 arrayProto 的空对象
+ const arrayMethods = Object.create(arrayProto)
+ // 修改 push 方法
+ arrayMethods['push'] = function (...args) {
+    const ob = this.__ob__
+    // 获取原始方法
+    const original = arrayProto['push']
+    // 执行原始方法
+    const result = original.apply(this, args)
+    // 因为执行 push 方法,数组数据有变化所以需要通知订阅者
+    ob.dep.notify()
+    // 同时对新添加的数据也进行响应式化
+    ob.observeArray(args)
+ }

class Observer {
    constructor(value) {
        // 省略 ...
        if (Array.isArray(value)) {
            // 对数组进行响应式处理
+            this.protoAugment(value, arrayMethods)
            // 对数组的每一项都进行响应式处理
            this.observeArray(value)
        } else {

        }
    }

+    protoAugment(target, src) {
+        // 覆盖原型对象
+        target.__proto__ = src
+    }
    // 省略 ...
}

至此我们通过重构就实现了 Observer 类,这一节没有涉及到发布订阅模式和数据响应式相关的内容,只是一下编程技巧的内容,而之所以有这一节是为了我们的代码结构更贴近 Vue2 源码的实现。通过这一节的实现,我们也可以知道发布订阅模式是如何在 Vue2 数据响应式中实现的。

那么从发布订阅模式的角度来看所谓 Observer 类,其实是一个发布者或者叫被观察者,虽然它的类命叫 Observer 翻译过叫观察者,但从观察者模式的角度来看,它不能叫观察者,因为它并没有向哪个被观察者进行订阅操作。但它又不是一个纯粹的发布者,它主要作用是将数据对象转换为响应式对象,使得当数据发生变化时能够触发相应的更新操作,它同时通过递归遍历数据对象中的所有属性,为每个属性设置 gettersetter 来实现数据的劫持和监听,从功能上来看它是在观察自己的属性。

从代码结构上来看,它的发布订阅模式的实现跟传统标准的发布订阅模式的结构还是存在很大差别的,但正如我们上篇文章中所说的那样,我们并不能从代码结构上去判断是否属于什么模式,而是从代码意图去判断。

订阅者中介实现

我们知道 Vue2 中的订阅者是通过 Watcher 类来实现的,也就是我们上一篇文章中所讲的订阅者中介

我们先实现一个订阅者中介:

class Watcher {
    constructor(fn) {
        // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
        this.getter = fn
        // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
        this.get()
    }
    get() {
        // 通过 Dep.target 来设置当前的订阅者是谁
        Dep.target = this
        this.getter()
        Dep.target = null
    }
    // 接受发布者通知的更新方法
    update() {
        this.getter()
    }
}

我们这里设计的订阅者中介类的实现跟我们上一篇中的订阅者中介类的实现,最大的不同就是,这里的设计需要在初始化的时候就要去执行一次订阅者所传的参数函数,因为在 web 应用应用中,应用需要初始化。

我们在实例化订阅者的时候,就把该订阅者需要做的事情当成参数传进去:

new Subscriber(() => {
    console.log(`郭靖收到的公众号文章:${weChatOfficialAccount.article}`)
}) 

同时 Dep 类的 notify 方法也需要修改一下:

class Dep {
  // 通知订阅者
  notify() {
-    this.subs.forEach(sub => sub())
+    this.subs.forEach(sub => sub.update())
  }
}

defineReactive 函数也需要修改:

- // 订阅者中间变量
- let activeEffect
// 通过闭包把劫持的属性值进行缓存
function defineReactive(data, key, val) {
    // 省略 ...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
-            if (activeEffect){
+            if (Dep.target){
-                dep.addSub(activeEffect)
+                dep.addSub(Dep.target)
                // 如果存在 childOb 则说明属性值是一个对象,则也需要对该对象进行订阅者收集,从发布订阅模式的角度看,这是一个订阅操作
-                if (childOb) childOb.dep.addSub(activeEffect) 
+                if (childOb) childOb.dep.addSub(Dep.target) 
            }
            return val
        },
        set(newVal) {
            // 省略 ...
        }
    })
}

这样我们就可以很方便通过一下进行测试了:

// 定义公众号
const weChatOfficialAccount = {
    // 文章内容
    article: '',
    author: '',
    date: '',
    arr: ['掘金']
}
observe(weChatOfficialAccount)
// 初始化订阅者
new Watcher(() => {
    console.log(`收到的公众号文章:${weChatOfficialAccount.arr.join('====')}`)
})

// 公众号发布文章则直接通过给属性赋值的方式
weChatOfficialAccount.arr.push('通过 Object.defineProperty 方法实现订阅发布模式')

打印结果如下:

C03.png

小结

Dep 和 Watcher 互为订阅者

我们通过上文知道 Dep 类其实是一个消息代理或者叫事件总线,而 Watcher 则是一个订阅者,但我们前面也留了一个引子,说它们还有一层关系,就是互为订阅者。那么既然 DepWatcher 互为订阅者,也就是说它们其实也是一个发布者的角色。所以现实系统中的应用远远要比我们所学的所谓标准模式要复杂得多。

我们知道在 Vue2 中可以通过 Options 选项设置 watcher 来实现对响应式数据的监听,其实还可以通过 this.$watcher() 来实现对响应式数据的监听,使用方法都是一样的,唯一的不同就是 this.$watcher() 会返回一个函数,这个函数的作用就是停止对响应式数据的监听。

那么要实现停止对响应式数据的监听则需要知道那些 Dep 记录了当前的 Watcher,我们就需要通知那些 Dep 取消订阅当前的 Watcher。那么要实现这个功能,就需要 Watcher 也进行记录自己订阅了哪些 Dep,当取消对响应式数据的监听的时候,就从当前 Watcher 的订阅记录里去通知那些 Dep 取消自己的订阅。

比如说我们在 Vue2 当中有这么一个功能:

const unwatch = this.$watch(function(){
      return this.name + this.age + this.sex
    }, function(newValue, oldValue){
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

我们接下来去实现这个功能,也就是实现 Vue2 的 $watcher() 功能。以下是 Vue2 官网对 $watcher API 的一些参数和功能的介绍。

  • [vm.$watch( expOrFn, callback, options )]

  • 参数

    • {string | Function} expOrFn

    • {Function | Object} callback

    • {Object} [options]
      
      • {boolean} deep
      • {boolean} immediate
  • 返回值{Function} unwatch

  • 用法

    观察 Vue 实例上的一个表达式或者一个函数计算结果的变化。回调函数得到的参数为新值和旧值。表达式只接受简单的键路径。对于更复杂的表达式,用一个函数取代。

这个 $watcher 的功能从发布订阅模式的角度可以看成是,第一个参数是订阅者要做的事情,第二个参数是在做完事情后拿到结果再通过第二参数输出结果,而且是每次所依赖的响应式数据发生变化后都需要执行第二个参数函数,输出新的结果。

那么我们先实现下面的功能:

new Watcher(function() {
   return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
   console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
})

我们对 Watcher 类做以下修改:

class Watcher {
-    constructor(fn) {
+    constructor(fn, cb) {
+        this.cb = cb
       // 让每个订阅者所需要做的事情通过参数的形式传进来,这样更灵活,拓展性更强
       this.getter = fn
       // 初始化的时候直接读取触发订阅者收集,因为这样设计符合 web 应用的特性
-        this.get()
+        this.value = this.get()
   }
   get() {
+        let value
       // 通过 Dep.target 来设置当前的订阅者是谁
       Dep.target = this
+        value = this.getter()
       Dep.target = null
+        return value
   }
   // 接受发布者通知的更新方法
   update() {
     // 获取新值
-      this.getter()
+      const value = this.getter()
+      // 设置旧值
+      const oldValue = this.value
+      // 更新值
+      this.value = value
+      if (this.cb) {
+        // 因为是用户写的,有可能存在错误
+        try {
+          this.cb(value, oldValue)
+        } catch(err) {
+          throw err
+        }
+      }
   }
}

我们进行测试:

weChatOfficialAccount.article = '第一次更新'
weChatOfficialAccount.article = '第二次更新'

测试结果如下:

C04.png

我们看到正确打印了结果。

有了以上的基础功能,接下来我们就很容易实现 $watcher API,代码如下:

function $watcher(expOrFn, cb) {
    const watcher = new Watcher(expOrFn, cb)
}

我们知道 $watcher API 是有很多配置选项的,也就是第三个参数,比如立即执行回调就是通过第三个参数配置 immediatetrue 来实现的,下面我们也来实现它:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

在 Vue2 中 Watcher 实例是分为系统的 Watcher 和用户的 Watcher 的,像我们在组件里面通过配置 watcher,就是用户的 Watcher,怎么体现区分呢?我们下面来设置,其实也很简单:

function $watcher(expOrFn, cb, options) {
    // 因为 options 有可能不存在,要做兼容处理
    options = options || {}
+    // 设置用户级的 Watcher
+    options.user = true
    const watcher = new Watcher(expOrFn, cb, options)
    // 如果 immediate 为 true 则立即执行回调函数 
    if (options.immediate) {
      try {
        cb(watcher.value)
      } catch (error) {
        throw new Error(error)
      }
    }
}

接着修改 Watcher 类:

class Watcher {
-    constructor(fn, cb){
+    constructor(fn, cb, options) {
+      if (options) {
+          this.user = !!options.user
+      } else {
+          this.user = false
+      }
      // 省略...
    }
    update() {
      // 省略...
-      if (this.cb) {
+      if (this.user) {
        // 省略...
      }
    }
}

修改也很简单,从上面的修改可以看得出,只有用户级的 Watcher 才会在更新的时候执行回调函数。

接下来我们测试立即回调功能:

$watcher(function() {
    return `要做的事情,获取文章:${weChatOfficialAccount.article}`
}, function(newValue, oldValue) {
    console.log(`新结果是:${newValue},旧结果是:${oldValue}`)
}, { immediate: true })

我们可以看到初始化的时候,就立即执行回调函数了。

C05.png

立即执行,旧值为 undefined,符合如期。

实现了上面的基础部分的功能,我们就可以实现重要的功能了,取消订阅。

function $watcher(expOrFn, cb, options) {
     // 省略...
+    // 返回一个可以取消订阅的函数
+    return function unwatchFn () {
+      watcher.teardown()
+    }
}

我们这里通过 Watcher 实例对象的 treardown 方法去取消订阅,其实是要去通知那些记录了该 Watcher 的 Dep 去删除其记录中的该 Watcher。那么我们怎么知道哪些 Dep 记录该 Watcher 呢?所以我们就需要在 Watcher 中记录其订阅了的 Dep。从发布订阅模式的角度来说就是 Dep 要对 Watcher 进行订阅,Dep 是订阅者,Watcher 是发布者,而我们之前是 Watcher 对相关的属性的 Dep 进行订阅,Watcher 是订阅者,相关属性的 Dep 是发布者。

我们首先对 Watcher 实现发布订阅的功能,代码迭代如下:

class Watcher {
    constructor(fn, cb, options) {
        // 省略...
+        this.deps = []
        // 省略...
    }
+    addDep(dep) {
+        this.deps.push(dep)
+    }
+    // 取消订阅
+    teardown() {
+        let i = this.deps.length
+        while (i--) {
+            this.deps[i].removeSub(this)
+        }
+    }
}

我们这里的取消订阅是通过 Watcher 所记录的 Dep 实例对象去执行 Dep 上的 removeSub 方法去把自己删除,这样将来 Dep 触发更新的时候,就通知不了自己了,也就执行不了 update 方法了。

接下来我们实现 Dep 类上的 removeSub 方法,迭代代码如下:

class Dep {
  // 省略..

+  // 取消订阅
+  removeSub (sub) {
+    // 找到需要取消的订阅者
+    const index = this.subs.indexOf(sub)
+    if (index > -1) {
+        // 删除订阅者
+        this.subs.splice(index, 1)
+    }
+  }
  // 省略...
}

我们可以看到 Dep 类中的取消订阅功能,跟普通发布订阅中的取消订阅功能是一样的。

我们前面已经实现了 DepWatcher 的订阅,那么接下来就是 Watcher 怎么对 Dep 进行订阅了。我们知道不管是什么数据类型都是在 getter 中进行依赖收集的,所以要实现 WatcherDep 的订阅,也要从 getter 开始。我们在 getter 里面可以通过 Dep.target 获取到当前的 Watcher,也可以获取到当前属性对应的 Dep 实例对象,那么就可以互相添加订阅者了。

代码迭代如下:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
                dep.addSub(Dep.target)
+                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                Dep.target.addDep(dep)
                if (childOb){
                    childOb.dep.addSub(Dep.target)
+                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
+                    Dep.target.addDep(childOb.dep)
                }
            }
            return val
        },
        // 省略...
    })
}

接下来我们就可以进行测试了:

const unwatch = $watcher(function(){
    return weChatOfficialAccount.article + weChatOfficialAccount.author
}, function(newValue, oldValue) {
    console.log('新值为:', newValue)
    console.log('旧值为:', oldValue)
    if (newValue === 'cobyte') {
      // 停止监听
      unwatch()
    }
})

console.log('会打印新值旧值')
weChatOfficialAccount.article = 'co'
console.log('会打印新值旧值')
weChatOfficialAccount.author = 'byte'
console.log('不会打印新值旧值')
weChatOfficialAccount.article = 'cobyte'

我们发现如期打印了我们期待的结果:

C06.png

我们上面在 getter 中对 Dep 和 Watcher 进行相互订阅的操作,还可以进行优化一下,让代码更优雅。

class Dep {
    // 省略...
+  // 通过 depend 方法进行依赖收集
+  depend() {
+    if (Dep.target) {
+      // 在 Dep 中进行 Watcher 
+      Dep.target.addDep(this)
+    }
+  }
  // 省略...
}

接着在 Watcher 中调用 Dep 的方法添加自己

class Watcher {
    // 省略...
    addDep(dep) {
        this.deps.push(dep)
+        // 调用 Dep 实例的添加订阅方法添加自己
+        dep.addSub(this)
    }
    // 省略...
}

接着我们修改 getter 中代码:

function defineReactive(data, key) {
    // 省略...
    Object.defineProperty(data, key, {
        get() {
            // 存在订阅者中间变量就进行订阅者添加
            if (Dep.target){
-                dep.addSub(Dep.target)
-                // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                Dep.target.addDep(dep)
+                dep.depend()
                if (childOb){
-                    childOb.dep.addSub(Dep.target)
-                    // 通过 Dep.target 就可以添加 Watcher 对应的 Dep 了
-                    Dep.target.addDep(childOb.dep)
+                    childOb.dep.depend()
                }
            }
            return val
        },
        // 省略...
    })
}

经过我们的重构,getter 中的依赖收集相关代码变得清晰多了。

总结

从发布订阅模式的角度来理解 Vue 的数据响应式原理,就是发布订阅模式的具体运用的过程。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

HTML marquee 滚动标签:从基础属性到交互实战

作者 CharlesY
2026年4月10日 07:53

在网页开发中,滚动文字、公告通知是极为常见的需求,而 HTML 自带的 <marquee> 标签,正是实现滚动效果最快捷、最简单的方式之一。尽管在最新的 HTML 规范中,该标签已被标记为非标准标签、不推荐正式规范使用,但得益于其极简的用法、全浏览器兼容的特性,至今仍被广泛用于快速开发场景、后台系统、简易公告栏等需求中。

本文将系统梳理 <marquee> 标签的所有核心用法,从基础滚动、方向控制,到速度调节、鼠标交互,帮你一篇掌握这个标签的全部实用技巧。

一、基础用法:默认滚动效果

最基础的 <marquee> 标签无需任何额外属性,只需包裹需要滚动的内容,即可实现默认的横向滚动效果,也是我们最常用的入门写法。

<marquee>这是最基础的默认滚动文字效果</marquee>

效果说明:默认情况下,内容会从右向左无限循环滚动,也就是我们常说的“跑马灯”效果。

二、核心行为控制:behavior 属性

behavior 属性用于定义滚动的核心行为模式,是决定滚动动画类型的关键属性,共有 4 个核心可选值,对应不同的滚动效果。

1. 预设循环滚动(scroll,默认值)

scroll<marquee> 标签的默认行为,即使不写该属性,也会默认启用。内容会从容器一端滚动到另一端,循环往复,无间断重复。

<marquee behavior="scroll">这是预设的循环滚动效果(标签默认行为)</marquee>

2. 单次滑动效果(slide)

slide 滑动模式下,内容会从起始端滑动到容器的另一端,到达边界后立即停止滚动,不会循环播放。

<marquee behavior="slide">这是单次滑动效果,到达边界后自动停止</marquee>

3. 来回往返滚动(alternate)

alternate 是反弹往返效果,内容滚动到容器边界后,会自动反向弹回,实现来回往复的滚动,类似“撞墙反弹”的动画效果。

<marquee behavior="alternate">这是来回往返的反弹滚动效果</marquee>

三、滚动方向控制:direction 属性

direction 属性用于控制内容滚动的方向,支持 4 个方向值,可灵活实现横向、纵向的全方向滚动。

1. 向左滚动(left,默认值)

标签的默认滚动方向,内容从右向左滚动,也是网站公告最常用的滚动方向。

<marquee direction="left">内容从右向左滚动(标签默认方向)</marquee>

2. 向右滚动(right)

与默认方向相反,内容从左向右滚动。

<marquee direction="right">内容从左向右滚动</marquee>

3. 向上滚动(up)

纵向滚动模式,内容从下向上滚动,常用于竖向公告、滚动列表等场景。

<marquee direction="up" height="60">内容从下向上滚动<br>第二行滚动内容</marquee>

4. 向下滚动(down)

纵向滚动模式,内容从上向下滚动。

<marquee direction="down" height="60">内容从上向下滚动</marquee>

注意:使用纵向滚动(up/down)时,建议给标签设置固定的 height 高度,否则容器会被内容完全撑开,可能无法看到预期的滚动效果。

四、循环次数控制:loop 属性

loop 属性用于设置滚动的循环次数,标签默认值为 -1,代表无限循环滚动。

<marquee loop="2">这段内容只会滚动2次,完成后自动停止</marquee>

五、容器尺寸控制:width 与 height 属性

widthheight 属性分别用于设置滚动容器的宽度和高度,支持像素值、百分比等单位,标签默认宽度为 100%(撑满父容器),高度为内容的原生高度。

<!-- 设定宽度180px,高度30px的滚动容器 -->
<marquee width="180" height="30" bgcolor="#f5f5f5">固定尺寸的滚动容器</marquee>

提示:属性值的单位可省略,默认单位为像素(px);也可使用百分比适配,比如 width="50%" 代表宽度为父容器的 50%。

六、背景颜色设置:bgcolor 属性

bgcolor 属性用于快速设置滚动容器的背景颜色,支持十六进制颜色码、RGB 值、标准颜色名等多种写法。

<!-- 红色背景滚动容器,注意十六进制颜色码需加#前缀 -->
<marquee bgcolor="#FF0000" color="#ffffff">红色背景的滚动文字</marquee>

避坑提示:十六进制颜色码必须加 # 前缀(如 #FF0000),否则部分浏览器无法正确识别颜色。

七、滚动速度精细控制:scrollamount 与 scrolldelay 属性

想要精准调整滚动的快慢与流畅度,就需要用到 scrollamountscrolldelay 这两个属性,二者配合可实现任意速度的滚动效果。

1. scrollamount:滚动步长,核心控制滚动速度

scrollamount 属性定义了滚动动画每一次移动的像素距离,数值越大,单次滚动距离越长,整体滚动速度越快,标签默认值为 6。

<!-- 快速滚动,每次移动30px -->
<marquee scrollamount="30">超快速滚动效果</marquee>
<!-- 慢速滚动,每次移动2px -->
<marquee scrollamount="2">超慢速滚动效果</marquee>

2. scrolldelay:滚动间隔,控制滚动流畅度

scrolldelay 属性定义了两次滚动之间的时间间隔,单位为毫秒(ms),数值越大,滚动间隔越长、整体速度越慢,标签默认值为 85。

<!-- 每300毫秒滚动一次,实现卡顿式步进滚动 -->
<marquee scrolldelay="300">间隔步进滚动效果</marquee>

实用技巧:日常开发中,优先调整 scrollamount 控制速度即可,scrolldelay 保持默认值就能获得流畅的滚动效果。

八、鼠标交互事件:悬停停止、离开继续

这是 <marquee> 标签最实用的进阶用法,通过内置的 start()stop() 方法,配合鼠标事件,可实现“鼠标悬停停止滚动,鼠标离开继续滚动”的效果,极大提升用户的阅读体验。

1. 鼠标经过时停止滚动

<marquee onmouseover="this.stop()">鼠标移到我上面,滚动就会停止</marquee>

2. 鼠标离开时开始滚动

<marquee onmouseout="this.start()">鼠标离开我,就会启动滚动</marquee>

3. 【最常用】组合用法:悬停停止,离开继续

这是实际开发中 90%的场景都会用到的标准写法,兼顾展示效果和用户阅读体验,可直接复制使用:

<marquee 
  onmouseover="this.stop()" 
  onmouseout="this.start()"
  scrollamount="3"
>
  【公告】鼠标悬停即可暂停滚动,方便阅读完整内容,鼠标离开后自动继续滚动
</marquee>

九、综合实战示例

下面提供一个可直接复制使用的完整网站公告栏示例,整合了所有常用属性,适配绝大多数常规滚动场景:

<!-- 网站公告栏完整示例 -->
<marquee 
  behavior="scroll"
  direction="left"
  width="100%"
  height="40"
  bgcolor="#fef6e4"
  scrollamount="4"
  loop="-1"
  onmouseover="this.stop()"
  onmouseout="this.start()"
>
  【重要通知】本站将于2024年XX月XX日00:00-06:00进行系统维护,维护期间部分功能可能无法正常使用,敬请谅解!
</marquee>

十、注意事项与替代方案

  1. 兼容性<marquee> 标签在 Chrome、Firefox、Safari、Edge 等所有主流浏览器,甚至老旧 IE 浏览器中都有完美的兼容性,无需额外处理兼容问题。
  2. 标准性:该标签并非 HTML 官方标准标签,W3C 规范已不推荐使用,大型商业项目、对代码规范有严格要求的项目,建议使用 CSS @keyframes 动画实现滚动效果。
  3. 局限性<marquee> 仅能实现基础的滚动效果,复杂的自定义缓动、多内容轮播、分段动画等需求,仍需通过 CSS 或 JavaScript 实现。

前端架构演进:基于AST的常量模块自动化迁移实践

作者 禅思院
2026年4月10日 07:49

前端架构演进:基于AST的常量模块自动化迁移实践

从“硬编码”到“全自动”:一次常量模块重构的工程化探索

在这里插入图片描述

一、背景与痛点

在许多中大型前端项目中,常量管理常常是一个被忽视但又十分重要的环节。随着业务迭代,常量定义方式可能发生变化,历史代码中也可能沉淀出各种“不规范”的模式。

在我们的项目中,常量定义最初采用了一种集中式导出方式:

// src/constants/Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
  // ... 数十个常量
}

而在业务代码中,这些常量通过一个“万能”的 @/locales 模块统一导入,并以 Constants_expert.default.STATUS_PENDING 的形式使用:

// 旧代码片段
import { Constants_expert } from '@/locales';

if (status === Constants_expert.default.STATUS_APPROVED) { ... }

这种模式存在几个严重问题:

  1. Tree Shaking 失效export default 对象导致整个常量对象被打包,无法按需剔除。
  2. 命名空间冗余:每次使用都要写 .default,代码冗长且容易出错。
  3. 模块职责混乱@/locales 本应是国际化模块,却承担了常量聚合的职责。
  4. 可维护性差:新增常量文件需要手动修改 @/locales 的导出,极易遗漏。

为了彻底解决这些问题,我们决定进行两项重构:

  • 常量文件:将 export default { ... } 拆解为多个 export const,实现具名导出。
  • 业务代码:将所有 Constants_xxx.default.PROP 替换为直接使用 PROP,并添加对应的具名导入。

项目涉及 30+ 个常量文件200+ 个业务文件,手工修改不仅耗时,而且极易出错。于是,我们开发了两个基于 AST(抽象语法树) 的自动化迁移脚本,实现了零人工干预的平滑过渡。

本文将从技术实现、难点攻克、工程化落地三个维度,深度剖析这次自动化重构的全过程。


二、整体方案设计

整个迁移流程分为两个独立的阶段,必须严格按顺序执行

graph LR
    A[常量文件] -->|transform-const.js| B[具名导出常量]
    C[业务代码] -->|transform-project.js| D[直接引用+具名导入]
    B -.->|提供导出变量列表| D
  • 第一阶段:扫描 src/constants/*.ts,将每个文件中的 export default 对象转换为多个 export const 语句。
  • 第二阶段:扫描 src/views 下的所有 .vue.ts.js 文件,识别旧的导入模式,分析实际使用的常量,删除旧导入,生成新的具名导入,并替换代码中的引用。

两个脚本均支持 --dry-run 预览模式,并在修改前自动创建 .bak 备份文件,确保操作可逆。


三、第一阶段:常量文件格式转换(transform-const.js)

3.1 核心目标

将这样的代码:

// Constants_expert.ts
export default {
  STATUS_PENDING: 0,
  STATUS_APPROVED: 1,
}

转换为:

export const STATUS_PENDING = 0;
export const STATUS_APPROVED = 1;

同时保留所有注释(文件头注释、属性上方注释等)。

3.2 AST 操作流程

我们使用 Babel 全家桶完成这次转换:

  • @babel/parser:将源码解析为 AST
  • @babel/traverse:遍历和修改 AST 节点
  • @babel/types:构建新的 AST 节点
  • @babel/generator:将 AST 还原为代码

核心步骤:

  1. 解析源码,指定 sourceType: 'module'plugins: ['typescript'] 以支持 TS 语法。
  2. 遍历 AST,找到 ExportDefaultDeclaration 节点,并判断其声明是否为 ObjectExpression
  3. 移除该默认导出节点
  4. 遍历对象的每个属性,对每个属性构建一个 ExportNamedDeclaration 节点,内部包裹 VariableDeclaration 类型为 const
  5. 保留注释:将原属性的 leadingCommentstrailingComments 赋值给新节点。
  6. 重新生成代码,并写回原文件。

关键代码片段:

traverse(ast, {
  ExportDefaultDeclaration(path) {
    if (t.isObjectExpression(path.node.declaration)) {
      defaultExportObject = path.node.declaration;
      path.remove(); // 移除整个 export default
    }
  },
});

defaultExportObject.properties.forEach((prop) => {
  const propName = prop.key.name;
  const propValue = prop.value;
  const exportDecl = t.exportNamedDeclaration(
    t.variableDeclaration('const', [
      t.variableDeclarator(t.identifier(propName), propValue),
    ])
  );
  // 保留注释
  if (prop.leadingComments) exportDecl.leadingComments = prop.leadingComments;
  exportConstNodes.push(exportDecl);
});

3.3 易错点与防御

  • 非对象默认导出:某些常量文件可能已经是 export const 格式,或者导出一个函数。脚本会检测并跳过,避免破坏已有代码。
  • 属性名非标识符:如果对象的键是字符串字面量(如 "my-const": 123),则无法转换为合法的变量名,脚本会给出警告并跳过该属性。
  • 文件备份:转换前自动创建 .bak 文件,防止误操作导致代码丢失。

四、第二阶段:业务代码引用迁移(transform-project.js)

这是整个方案中最复杂的部分,需要同时处理 JavaScript/TypeScriptVue SFC 文件,并且要保证转换后的代码语法正确、依赖完整。

4.1 动态发现常量文件

第一阶段完成后,src/constants 下的每个 .ts 文件都导出了一批具名常量。我们需要知道每个常量文件导出了哪些变量名,以便在第二阶段验证引用的有效性。

function loadAllConstantFiles() {
  const constantFiles = glob.sync(path.join(CONSTANTS_DIR, '*.ts'), { absolute: true });
  const constantMap = new Map(); // key: 文件名(如 Constants_expert), value: { filePath, exportedNames }

  for (const filePath of constantFiles) {
    const ast = parser.parse(fs.readFileSync(filePath, 'utf-8'), { plugins: ['typescript'] });
    const exportedNames = new Set();
    traverse(ast, {
      ExportNamedDeclaration(path) {
        if (t.isVariableDeclaration(path.node.declaration) && path.node.declaration.kind === 'const') {
          path.node.declaration.declarations.forEach(d => {
            if (t.isIdentifier(d.id)) exportedNames.add(d.id.name);
          });
        }
      },
    });
    constantMap.set(path.basename(filePath, '.ts'), { filePath, exportedNames });
  }
  return constantMap;
}

这样我们就获得了所有常量文件的“导出变量白名单”。

4.2 识别旧的导入模式

在业务代码中,旧的导入语句通常长这样:

import { Constants_expert, Constants_supplier_portrait } from '@/locales';

我们需要找到这些导入,并记录每个本地标识符对应的常量集合名(例如 Constants_expert 对应 Constants_expert 集合)。

使用 AST 遍历 ImportDeclaration,匹配 source.value === '@/locales',然后遍历 specifiers,只处理 ImportSpecifier 类型:

traverse(ast, {
  ImportDeclaration(path) {
    if (path.node.source.value === OLD_IMPORT_SOURCE) {
      path.node.specifiers.forEach(spec => {
        if (t.isImportSpecifier(spec)) {
          const importedName = spec.imported.name;
          const localName = spec.local.name;
          if (constantMap.has(importedName)) {
            oldLocalToConstantMap.set(localName, importedName);
            shouldRemove = true;
          }
        }
      });
      if (shouldRemove) path.remove(); // 删除整条导入语句
    }
  },
});

4.3 替换成员访问表达式

旧的引用方式有两种常见形态:

  • Constants_expert.default.STATUS_PENDING
  • Constants_expert.STATUS_PENDING(某些早期代码省略了 .default

我们需要将它们统一替换为 STATUS_PENDING,并记录下该常量名被使用了。

通过 AST 遍历 MemberExpression,找到根标识符,判断是否在 oldLocalToConstantMap 中,然后解析属性链,提取出最终属性名:

traverse(ast, {
  MemberExpression(path) {
    const root = findRootIdentifier(path.node);
    if (!root) return;
    const localName = root.name;
    if (!oldLocalToConstantMap.has(localName)) return;

    const constantSetName = oldLocalToConstantMap.get(localName);
    const chain = getPropertyChain(path.node);
    let propName = null;
    if (chain.length >= 3 && chain[1] === 'default') {
      propName = chain[2];
    } else if (chain.length >= 2) {
      propName = chain[1];
    }

    if (propName && constantMap.get(constantSetName).exportedNames.has(propName)) {
      // 记录需要导入的变量
      neededImports.get(constantSetName).add(propName);
      // 替换整个节点为一个简单的标识符
      path.replaceWith(t.identifier(propName));
    }
  },
});

4.4 Vue SFC 的特殊处理

Vue 单文件组件包含 <template><script><script setup> 等多个块,需要分别处理。

Script 块:将块内的代码提取出来,调用上述的 transformScript 函数,得到新的代码和需要的导入变量。注意一个 SFC 可能同时存在 <script><script setup>,需要分别处理并合并导入变量。

Template 块:模板中也可能直接使用 Constants_expert.default.STATUS_PENDING 表达式。由于模板不是完整的 JavaScript,用 AST 解析成本较高,我们采用正则替换的方式。

但正则替换有几个坑:

  • 常量名可能包含正则元字符(如 +.),需要转义。
  • 需要同时匹配 .default 和没有 .default 的情况。
  • 替换后要记录使用了哪些变量,以便生成导入。

我们构建动态正则:

const safeName = escapeRegExp(constName);
const regexWithDefault = new RegExp(`\\b${safeName}\\.default\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');
const regexWithoutDefault = new RegExp(`\\b${safeName}\\.([a-zA-Z_][a-zA-Z0-9_]*)\\b`, 'g');

匹配后,将 Constants_expert.default.STATUS 替换为 STATUS,并将 STATUS 加入 neededImports。

4.5 生成新的导入语句

经过上述分析,我们得到了每个常量集合需要导入的具名变量列表。但这里有一个隐蔽的坑:不同常量文件可能导出同名的变量(例如 Constants_expertConstants_supplier 都导出了 STATUS),如果直接生成 import { STATUS } from ... 两次,会产生语法错误。

因此,我们必须先检测冲突:

const varToConstMap = new Map();
for (const [constName, vars] of neededImportsTotal) {
  for (const v of vars) {
    if (varToConstMap.has(v) && varToConstMap.get(v) !== constName) {
      throw new Error(`变量名冲突: "${v}" 同时出现在 "${varToConstMap.get(v)}" 和 "${constName}" 中,请手动重命名其中一个导出变量`);
    }
    varToConstMap.set(v, constName);
  }
}

如果没有冲突,再生成导入语句。导入路径需要将绝对路径转换为 @/ 开头的别名:

const srcDir = path.join(rootDir, 'src');
let importPath = constantFilePath.replace(srcDir, '@/').replace(/\.ts$/, '');
importPath = importPath.replace(/\\/g, '/');

最后,将导入语句插入到文件顶部(如果有 script 块则插入到第一个 script 块的开始位置)。


五、技术难点与解决方案

5.1 路径别名动态转换

最初我们使用 path.relative 然后替换 ../@/,但当文件深度超过两层时,会出现 @/../../constants/xxx 的错误路径。解决方案:基于项目根目录的 src 进行绝对路径替换,直接构造 @/constants/xxx,简单可靠。

5.2 多个 <script> 块的替换位置

Vue SFC 可能同时存在 <script><script setup>,它们的起始和结束偏移量不同。我们需要记录每个块的 loc.start.offsetloc.end.offset,分别替换。并且由于替换后文件长度会变化,必须从后往前依次替换,避免位置偏移错误。

5.3 模板正则的精确匹配

模板中可能包含字符串字面量,例如:

<div :title="'Constants_expert.default.STATUS'"></div>

我们不应该替换引号内的内容。由于 Vue 模板语法的复杂性,完全避免误判需要解析模板 AST,成本过高。我们采用了一个折中方案:只替换独立表达式中的匹配,通过正则的单词边界 \b 来减少误判。在实际项目中,常量名很少出现在字符串内部,因此风险可控。

5.4 保留代码格式与注释

AST 转换后重新生成的代码会丢失原格式(空行、缩进等)。为了最小化 diff,我们使用了 generate{ retainLines: true, comments: true } 选项,尽可能保留原始行号和注释位置。对于 template 的正则替换,我们只替换匹配部分,其余原样保留。


六、工程化落地与自动化流程

为了确保迁移过程平滑、可回滚,我们设计了一套完整的执行流程:

# 1. 全量备份(使用 git 分支)
git checkout -b feature/migrate-constants

# 2. 执行常量文件转换(dry-run 预览)
node scripts/transform-const.js --dry-run
node scripts/transform-const.js

# 3. 执行项目引用迁移(dry-run 预览)
node scripts/transform-project.js --dry-run
node scripts/transform-project.js

# 4. 运行类型检查、单元测试,确保无报错
npm run type-check
npm run test

# 5. 提交变更
git add .
git commit -m "refactor: migrate constants to named exports"

两个脚本都内置了 --dry-run 模式和自动 .bak 备份。即便转换出现问题,也可以快速恢复:

# 恢复所有备份文件
find src -name "*.bak" | while read bak; do mv "$bak" "${bak%.bak}"; done

七、成果与思考

通过这两个脚本,我们在 10 分钟内完成了原本需要 2 人天 的手工重构工作,且零失误。转换后的代码:

  • Tree Shaking 友好:打包体积减少约 15%(未使用的常量被自动剔除)。
  • 可读性提升:代码中直接使用 STATUS_PENDING 而非冗长的 Constants_expert.default.STATUS_PENDING
  • 维护成本降低:新增常量文件无需任何额外配置,脚本自动发现。

更重要的是,这次实践让我们深刻体会到 AST 驱动重构 的巨大威力。无论是代码格式化、框架升级,还是架构调整,只要存在“模式化的代码变换”,都可以借助 AST 工具实现自动化。

未来拓展方向

  • 支持更复杂的引用模式:如 Constants_expert['default'].STATUSConstants_expert[someVar].STATUS,这些可以通过增强 MemberExpression 的递归分析来支持。
  • 集成到 CI 流水线:当常量文件结构发生变化时,自动触发迁移脚本,确保代码库始终保持统一风格。
  • 可视化迁移报告:输出每个文件转换前后的 diff,以及冲突变量列表,便于人工审核。

八、总结

本文详细介绍了如何利用 Babel AST 和 Vue 编译器,完成一次大型常量模块的重构迁移。从最初的痛点分析,到两个阶段脚本的设计,再到各种技术坑点的解决方案,我们不仅解决了实际问题,也沉淀了一套可复用的自动化重构方法论。

如果你也面临类似的“技术债务”清理任务,不妨尝试用 AST 武装自己——让机器去处理那些重复、枯燥的代码变换,把人解放出来做更有创造性的工作。

欢迎交流讨论,共同提升前端工程化水平。更多文章

CSS3 position 属性全面理解(实战版)

2026年4月9日 23:07

CSS3 的 position 属性,核心作用是控制元素在页面中的定位方式,决定元素如何脱离正常文档流、如何相对于父元素/视口进行定位,是布局中实现“悬浮、固定、层叠”等效果的核心属性,常用且易混淆,本篇将从基础到实战,彻底讲懂。

核心原则:position 的取值决定元素的定位模式,配合 toprightbottomleft(以下简称“方位属性”)控制具体位置,未设置方位属性时,元素默认保持原有位置。

一、position 5个取值(必学,按常用度排序)

position 有5个核心取值,其中 static 是默认值,relativeabsolutefixed 是高频常用值,sticky 是粘性定位(场景化常用),逐一拆解如下。

1. static(默认值:静态定位)

最基础的定位方式,元素遵循正常文档流,自上而下、从左到右排列,不受方位属性(top/right等)的影响,也不会脱离文档流。

/* 默认无需主动设置,所有元素默认都是 static */
.box {
  position: static;
  top: 20px; /* 无效,static 不识别方位属性 */
  left: 20px; /* 无效 */
  width: 100px;
  height: 100px;
  background: #00aaff;
}

特点:无法通过方位属性调整位置,完全遵循正常布局,一般用于“取消已设置的定位”(如覆盖父元素的定位)。

2. relative(相对定位)

元素不脱离正常文档流,保留自身在文档流中的原有位置,方位属性(top/right等)相对于自身原有位置进行偏移,不会影响其他元素的布局。

.box {
  position: relative;
  top: 20px; /* 相对于自身原有位置,向下偏移20px */
  left: 30px; /* 相对于自身原有位置,向右偏移30px */
  width: 100px;
  height: 100px;
  background: #ff7d00;
}

关键要点:

  • 不脱离文档流,自身原有位置会被保留(其他元素不会填充进来);
  • 偏移量相对于“自身原本的位置”(而非父元素);
  • 常用场景:作为 absolute 定位的“参考容器”(子绝父相),或微调元素位置。

3. absolute(绝对定位)【重点】

元素完全脱离正常文档流,不再保留自身原有位置(其他元素会填充其空位),方位属性相对于最近的已定位祖先元素(position 不为 static 的祖先)定位;若没有已定位祖先,则相对于根元素(html)定位。

/* 父容器设置 relative(作为参考) */
.parent {
  position: relative;
  width: 300px;
  height: 300px;
  background: #f5f5f5;
}
/* 子元素设置 absolute(相对于父容器定位) */
.child {
  position: absolute;
  top: 50px; /* 相对于父容器顶部,向下50px */
  right: 50px; /* 相对于父容器右侧,向左50px */
  width: 100px;
  height: 100px;
  background: #00cc66;
}

关键要点(避坑核心):

  • 脱离文档流,不占据页面空间,会“悬浮”在其他元素上方;
  • 定位参考物:优先找“最近的、position 为 relative/absolute/fixed/sticky”的祖先元素,没有则相对于 html(页面);
  • 高频场景:弹窗、下拉菜单、元素悬浮、精准定位(如按钮右上角的角标);
  • 易错点:若父元素未设置定位,子元素 absolute 会相对于页面定位,导致布局错乱。

4. fixed(固定定位)

元素完全脱离正常文档流,方位属性相对于浏览器视口(viewport) 定位,无论页面如何滚动,元素始终固定在视口的指定位置,不随页面滚动而移动。

/* 页面右下角固定按钮 */
.fixed-btn {
  position: fixed;
  bottom: 30px; /* 相对于视口底部,向上30px */
  right: 30px; /* 相对于视口右侧,向左30px */
  width: 60px;
  height: 60px;
  border-radius: 50%;
  background: #ff4444;
  color: white;
  text-align: center;
  line-height: 60px;
}

关键要点:

  • 脱离文档流,不占据页面空间,始终悬浮在视口固定位置;
  • 定位参考物是“浏览器视口”,与父元素无关;
  • 常用场景:固定导航栏、回到顶部按钮、悬浮客服按钮。

5. sticky(粘性定位)

“相对定位 + 固定定位”的结合体,元素默认遵循正常文档流,当页面滚动到指定位置时,自动切换为固定定位,固定在视口的指定位置;滚动超出范围后,恢复为相对定位。

/* 粘性导航栏 */
.sticky-nav {
  position: sticky;
  top: 0; /* 滚动到顶部距离为0时,固定 */
  width: 100%;
  height: 60px;
  background: #333;
  color: white;
  line-height: 60px;
  padding: 0 20px;
}

关键要点:

  • 未滚动到指定位置时,遵循正常文档流;滚动到阈值(top/right等设置的值)时,变为固定定位;
  • 必须设置方位属性(top/right/bottom/left),否则粘性效果无效;
  • 常用场景:粘性导航栏、列表标题悬浮(滚动时标题固定在顶部)。

二、核心对比(快速区分,避坑关键)

取值 是否脱离文档流 定位参考物 核心场景
static 无(不识别方位属性) 默认布局、取消定位
relative 自身原有位置 作为absolute参考容器、微调位置
absolute 最近的已定位祖先 / html 弹窗、悬浮元素、精准定位
fixed 浏览器视口 固定导航、回到顶部按钮
sticky 视滚动状态(未固定时否,固定时是) 正常流时自身,固定时视口 粘性导航、悬浮标题

三、实战案例(直接复制套用,覆盖高频场景)

案例1:子绝父相(最常用,精准定位)

场景:卡片内部的按钮、角标,相对于卡片精准定位,不影响卡片布局。

/* 父卡片 - 相对定位(参考容器) */
.card {
  position: relative;
  width: 280px;
  height: 380px;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.1);
  padding: 20px;
}
/* 子元素 - 绝对定位(相对于卡片) */
.card-badge {
  position: absolute;
  top: 10px;
  right: 10px;
  background: #ff4444;
  color: white;
  padding: 2px 8px;
  border-radius: 12px;
  font-size: 12px;
}
.card-btn {
  position: absolute;
  bottom: 20px;
  left: 50%;
  transform: translateX(-50%); /* 水平居中 */
  width: 80%;
  height: 40px;
  background: #00aaff;
  color: white;
  border: none;
  border-radius: 4px;
}

案例2:固定回到顶部按钮

场景:页面滚动时,右下角固定显示回到顶部按钮,点击可返回页面顶部。

.back-top {
  position: fixed;
  bottom: 30px;
  right: 30px;
  width: 50px;
  height: 50px;
  border-radius: 50%;
  background: rgba(0, 170, 255, 0.8);
  color: white;
  text-align: center;
  line-height: 50px;
  cursor: pointer;
  transition: all 0.3s;
}
.back-top:hover {
  background: #00aaff;
  transform: scale(1.1);
}

案例3:粘性导航栏

场景:页面顶部导航栏,滚动页面时,导航栏固定在视口顶部,不随页面滚动消失。

/* 页面头部 */
.header {
  height: 100px;
  background: #f5f5f5;
  line-height: 100px;
  text-align: center;
  font-size: 24px;
}
/* 粘性导航 */
.sticky-nav {
  position: sticky;
  top: 0;
  width: 100%;
  height: 60px;
  background: #333;
  color: white;
  padding: 0 20px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 导航菜单 */
.nav-menu {
  display: flex;
  gap: 30px;
  line-height: 60px;
}
/* 页面内容(用于测试滚动) */
.content {
  height: 1500px;
  padding: 20px;
}

四、避坑注意事项(必看)

  • absolute 定位的元素,若父元素未设置定位(position 为 static),会相对于 html 定位,容易导致布局错乱,建议遵循“子绝父相”原则。
  • fixed 定位的元素,会脱离文档流且相对于视口定位,若父元素有 transform 属性(如 scale、translate),会导致 fixed 定位失效(参考物变为父元素)。
  • sticky 定位必须设置方位属性(top/right等),否则无法触发粘性效果;且父元素不能有 overflow: hidden 属性,否则粘性效果失效。
  • 脱离文档流的元素(absolute、fixed),会覆盖在未脱离文档流的元素上方,可通过 z-index 属性调整层级(z-index 值越大,层级越高)。
  • relative 定位的元素,虽然不脱离文档流,但会创建“层叠上下文”,其内部的绝对定位元素会相对于它定位,而非相对于更外层的元素。

五、总结(快速记忆)

  1. 默认 static:不脱离流,方位无效;

  2. relative:不脱离流,相对于自身,做参考容器;

  3. absolute:脱离流,相对于已定位祖先,精准定位;

  4. fixed:脱离流,相对于视口,固定不动;

  5. sticky:混合定位,滚动触发固定,需设方位属性。

记住“子绝父相”“fixed 相对视口”“sticky 需设方位”这3个关键点,就能解决90%的定位场景,剩下的靠实战熟练即可。

零基础学会 Flex 布局(实战版)

2026年4月9日 23:00

Flex 布局(弹性布局)是 CSS 中最常用、最灵活的布局方式,核心作用是快速实现元素的对齐、分布、自适应,替代传统的 float、position 布局,适配所有现代浏览器,上手简单且实用性极强。

核心原则:给父容器设置 display: flex,父容器称为「flex 容器」,其直接子元素称为「flex 项目」,通过控制容器和项目的属性,实现各种布局效果。

一、核心基础(必学,3分钟掌握)

1. 开启 Flex 布局(第一步)

给需要布局的父容器添加 display: flex,即可开启弹性布局,项目会默认横向排列(水平主轴)。

/* 父容器 */
.flex-container {
  display: flex; /* 开启flex布局 */
  width: 100%;
  height: 300px;
  background: #f5f5f5;
}

/* 子项目 */
.flex-item {
  width: 100px;
  height: 100px;
  background: #00aaff;
  margin: 10px;
}

效果:3个项目横向排列,默认在容器顶部对齐,自动填满水平方向(未填充满时,项目之间有间隙)。

2. 两个核心概念(必记)

  • 主轴:项目排列的主要方向(默认「水平方向」,从左到右)
  • 交叉轴:与主轴垂直的方向(默认「垂直方向」,从上到下)

后续所有属性,都是围绕「主轴」和「交叉轴」的对齐、分布来设置。

二、容器属性(控制整体布局,最常用6个)

所有属性均设置在「父容器」上,控制子项目的排列方式,重点记前4个。

1. flex-direction(控制主轴方向,核心)

决定项目是横向、纵向排列,解决“水平/垂直布局”问题。

属性值 效果 常用场景
row(默认) 主轴水平,项目从左到右 导航栏、横向卡片
column 主轴垂直,项目从上到下 侧边栏、纵向列表
row-reverse 主轴水平,项目从右到左 反向排列(少见)
column-reverse 主轴垂直,项目从下到上 反向排列(少见)

2. justify-content(主轴对齐,最常用)

控制项目在「主轴」上的对齐方式,解决“水平/垂直居中、分布”问题。

属性值 效果(主轴水平时) 高频场景
flex-start(默认) 项目靠左对齐 普通列表
center 项目水平居中 登录框、卡片居中
flex-end 项目靠右对齐 右侧按钮组
space-between 两端对齐,项目之间间距相等 导航栏(左右分布)
space-around 项目两侧间距相等,整体间距均匀 卡片布局、商品列表

3. align-items(交叉轴对齐,核心)

控制项目在「交叉轴」上的对齐方式,解决“垂直/水平居中”的另一半问题。

属性值 效果(主轴水平时) 高频场景
stretch(默认) 项目高度拉伸至与容器一致 等高卡片、导航栏
center 项目垂直居中 文字+图标对齐、卡片内容居中
flex-start 项目靠上对齐 顶部列表
flex-end 项目靠下对齐 底部按钮组

4. flex-wrap(控制换行,避免溢出)

默认情况下,项目会强制在一行显示,超出容器会溢出,用这个属性控制换行。

属性值 效果
nowrap(默认) 不换行,项目会被压缩
wrap 自动换行,超出一行时,新行在下
wrap-reverse 自动换行,超出一行时,新行在上

5. align-content(多行交叉轴对齐,少见)

只有当项目换行(flex-wrap: wrap)时才生效,控制多行项目在交叉轴上的整体对齐方式,用法和 justify-content 类似(center、space-between 等)。

6. flex-flow(简写属性,偷懒必备)

flex-direction + flex-wrap 的简写,顺序任意,默认值:flex-flow: row nowrap。

/* 等价于 flex-direction: column; flex-wrap: wrap; */
.flex-container {
  flex-flow: column wrap;
}

三、项目属性(控制单个项目,常用3个)

属性设置在「子项目」上,控制单个项目的大小、对齐方式,按需使用。

1. flex(核心,控制项目占比)

最常用属性,控制项目在主轴上的占比,替代 width/height,实现自适应,语法:flex: 数字(数字越大,占比越大)。

.flex-container {
  display: flex;
  width: 100%;
  height: 200px;
  background: #f5f5f5;
}
/* 三个项目,占比 1:2:1 */
.item1 { flex: 1; background: #00aaff; }
.item2 { flex: 2; background: #ff7d00; }
.item3 { flex: 1; background: #00cc66; }

效果:容器宽度被分成 4 份,item2 占 2 份,其余各占 1 份,自适应容器宽度。

补充:flex 的完整写法是 flex: flex-grow flex-shrink flex-basis,日常用简写(数字)即可。

2. align-self(单个项目交叉轴对齐)

覆盖父容器的 align-items 属性,单独控制某个项目的交叉轴对齐方式。

.flex-container {
  display: flex;
  align-items: center; /* 所有项目垂直居中 */
}
.item-special {
  align-self: flex-end; /* 单个项目靠下对齐 */
}

补充:flex 的完整写法是 flex: flex-grow flex-shrink flex-basis,日常用简写(数字)即可。

3. order(控制项目顺序)

默认所有项目 order: 0,数字越小,项目越靠前;可以用负数,实现“不改变HTML结构,调整显示顺序”。

.item1 { order: 2; } /* 第3个显示 */
.item2 { order: 1; } /* 第2个显示 */
.item3 { order: 0; } /* 第1个显示 */

四、实战案例(直接复制套用,覆盖80%场景)

案例1:水平居中 + 垂直居中(最常用)

适用于登录框、弹窗内容、卡片居中,一步到位。

.container {
  display: flex;
  justify-content: center; /* 主轴居中(水平) */
  align-items: center; /* 交叉轴居中(垂直) */
  width: 100vw; /* 视口宽度 */
  height: 100vh; /* 视口高度 */
  background: #f5f5f5;
}
.center-box {
  width: 300px;
  height: 200px;
  background: white;
  border-radius: 8px;
  text-align: center;
  line-height: 200px;
}

案例2:导航栏(两端对齐)

适用于网站导航,左侧logo、右侧菜单,两端分布。

.nav {
  display: flex;
  justify-content: space-between; /* 两端对齐 */
  align-items: center; /* 垂直居中 */
  padding: 0 20px;
  height: 60px;
  background: #333;
  color: white;
}
.nav-logo {
  font-size: 20px;
  font-weight: bold;
}
.nav-menu {
  display: flex; /* 菜单横向排列 */
  gap: 20px; /* 菜单之间间距 */
}

案例3:自适应卡片布局(换行)

适用于商品列表、卡片展示,自适应屏幕宽度,自动换行。

.card-container {
  display: flex;
  flex-wrap: wrap; /* 自动换行 */
  gap: 20px; /* 卡片之间间距 */
  padding: 20px;
  background: #f5f5f5;
}
.card {
  flex: 1; /* 自适应占比 */
  min-width: 250px; /* 最小宽度,避免太窄 */
  height: 200px;
  background: white;
  border-radius: 8px;
  padding: 15px;
}

案例4:垂直布局(侧边栏)

适用于侧边导航、垂直列表,纵向排列。

.sidebar {
  display: flex;
  flex-direction: column; /* 垂直排列 */
  width: 200px;
  height: 500px;
  background: #333;
  color: white;
}
.sidebar-item {
  padding: 15px 20px;
  border-bottom: 1px solid #444;
}
.sidebar-item:last-child {
  border-bottom: none;
}

五、注意事项(避坑关键)

  • 开启 flex 布局后,子项目的 float、clear、vertical-align 属性会失效,无需再用。
  • flex: 1 等价于 flex: 1 1 0%,会让项目自适应填充剩余空间,优先于固定 width/height。
  • justify-content 控制主轴,align-items 控制交叉轴,方向由 flex-direction 决定(别搞反)。
  • 项目换行时,用 gap 控制间距(比 margin 更简洁,不会出现最后一个项目多余间距)。
  • 兼容问题:Flex 布局支持 IE10+,现代浏览器(Chrome、Edge、Firefox、Safari)均完美支持,无需加前缀。

六、总结(快速记忆)

  1. 开启:父容器 display: flex

  2. 方向:flex-direction(row/column);

  3. 对齐:主轴 justify-content,交叉轴 align-items;

  4. 换行:flex-wrap: wrap;

  5. 占比:项目 flex: 数字;

记住这5点,就能解决80%的布局问题,剩下的靠实战熟练即可。

Vue 中实现文字滚动(跑马灯)的多种方式

2026年4月9日 22:51

在 Vue 里实现文字滚动(跑马灯) ,最常用、最稳的就两种:

  1. CSS 动画纯实现(简单、性能好)
  2. JS 控制滚动(可暂停、可控制速度)

下面直接给你可复制粘贴的 Vue 组件代码


方式1:纯 CSS 跑马灯(推荐)

Marquee.vue

<template>
  <div class="marquee-wrap">
    <div class="marquee-content">
      {{ text }}
    </div>
  </div>
</template>

<script setup>
const text = '这里是需要滚动的文字,Vue 跑马灯效果,从右向左无限滚动~';
</script>

<style scoped>
.marquee-wrap {
  width: 100%;
  overflow: hidden;
  white-space: nowrap;
  background: #f5f5f5;
  padding: 8px 16px;
  border-radius: 8px;
}

.marquee-content {
  display: inline-block;
  animation: marquee 15s linear infinite;
}

@keyframes marquee {
  0% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(-100%);
  }
}
</style>

特点:

  • 一行无限滚动
  • 无 JS,性能最好
  • 鼠标悬浮暂停版往下看

方式2:hover 暂停 + 无缝滚动(更常用)

<template>
  <div class="box">
    <div class="marquee" @mouseenter="pause" @mouseleave="play">
      <div class="text" :style="{ animationPlayState }">
        {{ content }}
      </div>
    </div>
  </div>
</template>

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

const content = 'Vue3 无缝跑马灯,鼠标移入暂停,移出继续滚动~';
const animationPlayState = ref('running');

const pause = () => {
  animationPlayState.value = 'paused';
};
const play = () => {
  animationPlayState.value = 'running';
};
</script>

<style scoped>
.box {
  width: 100%;
  overflow: hidden;
  background: #f9f9f9;
  padding: 10px;
  border-radius: 6px;
}
.marquee {
  white-space: nowrap;
}
.text {
  display: inline-block;
  animation: move 12s linear infinite;
}
@keyframes move {
  0% { transform: translateX(100%); }
  100% { transform: translateX(-100%); }
}
</style>

方式3:真正无缝(无空白,首尾衔接)

适合公告、长文本:

<template>
  <div class="wrap">
    <div class="box">
      <span class="txt1">{{ text }}</span>
      <span class="txt2">{{ text }}</span>
    </div>
  </div>
</template>

<script setup>
const text = '这里是真正无缝跑马灯,没有空白间隔,一直循环滚动';
</script>

<style scoped>
.wrap {
  width: 100%;
  overflow: hidden;
  background: #fff8e1;
  padding: 8px 0;
}
.box {
  display: flex;
  width: max-content;
  animation: scroll 10s linear infinite;
}
.txt1, .txt2 {
  padding: 0 20px;
}
@keyframes scroll {
  0% { transform: translateX(0); }
  100% { transform: translateX(-50%); }
}
</style>

方式4:JS 控制滚动(可变速、可停止)

<template>
  <div class="box" style="overflow: hidden">
    <div class="text" :style="{ marginLeft: `${left}px` }">
      JS 控制跑马灯,可随时停止、加速、减速
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const left = ref(300);
let timer = null;

onMounted(() => {
  timer = setInterval(() => {
    left.value -= 1;
    if (left.value < -300) left.value = 300;
  }, 20);
});
onUnmounted(() => clearInterval(timer));
</script>

CSS3 阴影完全指南:box-shadow、text-shadow 详解

2026年4月9日 22:48

shadow 里最常用的两个阴影属性:

  • text-shadow:文字阴影(简单)
  • box-shadow:盒子/元素阴影(常用、功能强)

我用最通俗、能直接上手的方式给你讲清楚,附带复制即用的代码。


一、text-shadow 文字阴影

专门给文字加阴影,语法超级简单。

基础语法

text-shadow: 水平偏移 垂直偏移 模糊度 颜色;

参数解释

  1. 水平偏移:正数向右,负数向左
  2. 垂直偏移:正数向下,负数向上
  3. 模糊度:值越大越模糊(不能为负)
  4. 颜色:阴影颜色(可省略,默认文字颜色)

示例代码

/* 基础阴影 */
text-shadow: 2px 2px 4px #333;

/* 发光效果(最常用!) */
text-shadow: 0 0 8px #00aaff;

/* 多重阴影(用逗号分隔) */
text-shadow: 1px 1px 2px black, 0 0 1em blue, 0 0 0.2em blue;

二、box-shadow 盒子阴影(重点!)

div、图片、按钮、卡片加阴影,90% 网页美化都用它。

完整语法

box-shadow: 水平偏移 垂直偏移 模糊度 扩散半径 颜色 内外阴影;

6 个参数(必须记)

  1. h-shadow:水平偏移(必需)
  2. v-shadow:垂直偏移(必需)
  3. blur:模糊半径(越大越柔)
  4. spread:扩散半径(扩大/缩小阴影)
  5. color:阴影颜色
  6. inset内阴影(不加就是外阴影)

最常用写法(外阴影)

/* 卡片柔和阴影(推荐!) */
box-shadow: 0 2px 12px rgba(0,0,0,0.1);

内阴影(inset)

/* 凹陷效果 */
box-shadow: inset 0 0 10px #000;

多重阴影

box-shadow: 
  0 0 10px red,
  0 0 20px blue,
  0 0 30px green;

三、最实用的 6 种阴影效果(直接复制)

1. 标准卡片阴影(最常用)

box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);

2. 悬浮抬起效果(hover)

div{
  transition: 0.3s;
}
div:hover{
  box-shadow: 0 8px 24px rgba(0,0,0,0.15);
  transform: translateY(-3px);
}

3. 发光效果

box-shadow: 0 0 12px #00aaff;

4. 内阴影(凹陷)

box-shadow: inset 0 2px 8px rgba(0,0,0,0.2);

5. 细边框阴影(代替 border)

box-shadow: 0 0 0 1px #ccc;

6. 文字发光

text-shadow: 0 0 6px #fff, 0 0 12px #00aaff;

四、快速区分 & 记忆

属性 作用 最常用格式
text-shadow 文字阴影 2px 2px 4px #333
box-shadow 盒子阴影 0 2px 12px rgba(0,0,0,0.1)
  • 偏移:正右下,负左上
  • 模糊:越大越柔
  • 扩散:越大越大
  • 内阴影:加 inset

五、小技巧(高手必备)

  1. 阴影用 rgba 更自然:rgba(0,0,0,0.1)
  2. 垂直偏移 > 水平偏移更符合视觉习惯
  3. 配合 transition 做 hover 动画超好看
  4. 多重阴影 = 高级质感

总结

  • text-shadow:给文字加阴影,4个参数,简单
  • box-shadow:给盒子加阴影,6个参数,支持内外、多层
  • 最实用:0 2px 12px rgba(0,0,0,0.1) 卡片阴影
  • 想凹陷就加 inset

需要我给你做一个可在线调试的阴影演示页面吗?直接复制就能用!

❌
❌