普通视图

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

为什么生产环境很少手写流式响应:AI SDK 三层架构一次讲清

作者 前端Fusion
2026年4月10日 14:01

我们在 AI 应用开发 | 手写流式输出:把打字机效果背后的数据流拆开看 里面,已经把流式输出这件事手写跑通了。

但真把这套东西往聊天应用里接的时候,你很快就会感觉到,问题已经不是字怎么出来了,而是后面那一串和业务本身无关、却又必须有人接住的细节:

chunk 边界、半截 JSON、消息历史怎么记、流到一半要不要让用户停下。

今天聊的是 为什么要从手写切到 Vercel AI SDK

先划重点

手写流式响应跑通之后,往真实聊天应用走,会被两件事情拖住:

一类是看不见的协议翻译,拆 chunk、拼半个字、认结束标记; 另一类是看不见的对话状态,记历史、知道在不在流、能不能重生成。

AI SDK 把这两类工作,分散到三个位置接走:

  • 在最上游,把不同模型的 API 翻成一种统一格式
  • 在服务端,统一调模型 + 统一回一条结构化事件流
  • 在前端,统一维护消息数组和流状态

手写版不是不能跑,是越往前走越像在造基础设施

手写版跑通之后,真接聊天应用你会发现,花时间的地方已经不在打字机效果上了。

一部分时间花在协议边界上。

一个中文字符完全可能被拆到前后两个 chunk 里,一段 JSON 只拿到半截就 JSON.parse 直接炸掉,SSE 事件还得自己按 \n\n 切分、挑出 data 字段、认结束标记。

这些工作原本和业务没一点关系,但不接住一个,整条链就走不通。

chunk 是网络边界,不是业务消息边界。

chunk-vs-message-boundary.png

另一部分时间花在对话状态上。

手写版的前端 state 一开始通常就长这样:

const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

answer 这个字符串 state 一开始单轮够用,但一旦需求里出现多轮历史、重生成、从中间分叉,每条消息还得分清是用户还是 AI、是文字还是工具调用、有没有完成,这一串东西塞不进一个字符串里。

一段字符串记得住一句话,记不住一段对话。

answer-vs-messages.png

所以手写版的问题不是不能跑,是你一旦从 demo 往真实聊天应用走,就得在协议和状态这两层各造一套自己的基础设施

花时间的地方不再是业务,而是这些重复又容易出错的细节。


AI SDK 覆盖的是整条链,不是某一个点

聊到 Vercel AI SDK 之前,我先说一下我自己对它的认知是怎么变的。

我一开始也以为它就是个 React hook,useChat 一调就完事了。后来多看几次才发现,它其实是一整套工具

从模型怎么接进来、服务端怎么调模型、到前端怎么维护对话状态,整条链都覆盖了。

前面那两层工作,协议翻译状态管理不是某一个文件的问题,是从模型到前端整条链上散落的细节

能把这两层一起接住的,也只能是一套覆盖整条链的东西,而不是一个孤立的 hook 或者一个孤立的服务端函数。

我自己是用一家餐厅在脑子里记这条链的

一家餐厅要上菜,它先得有供应商。

不同供应商送来的东西规格千差万别,有的按斤、有的按箱,包装单据也都不一样。

如果后厨每接一家就要重学一遍人家的规矩,这家餐厅根本开不起来。

所以稍微大一点的餐厅都会有一个收货环节,不管哪家供应商送来的,都按统一的规格入库,后厨拿到的永远是同一种格式。

收货之后,后厨才开始干自己的活,按统一的菜单做菜,按统一的餐具盛出来,再交给前台。后厨不关心这块牛肉是哪家送的,它只认入库后的规格。

前台的工作又是另一回事。它不做菜,但它要记住这一桌点了什么、上到第几道、客人有没有催菜、能不能换菜。

AI SDK 这套架构,几乎就是把这家餐厅的分工原封不动搬过来了。

  • 不同模型厂商就是不同的供应商
  • provider adapter(@ai-sdk/xxx 就是收货环节,把不同厂商 API 的差异在这里一次性抹平
  • streamText + toUIMessageStreamResponse() 就是后厨,统一调模型 + 统一往外回一条结构化事件流
  • useChat 就是前台,记住这桌的整段对话现在是什么状态

ai-sdk-three-layer-chain.png

带着这张图往下读,后面三个节点就是这条链上的三个停靠点。


第一个节点:先把模型接进来

不同模型厂商的 API 本来就未必长一样,路径、鉴权 header、流式事件格式、文本字段路径都可能不一样。

你如果每次都直接对着厂商 API 写代码,很快就会反复写这一家怎么接、那一家怎么接。

这时候 AI SDK 的模型接入层就开始把这事接过去了。

provideradapter 这两个词后面会反复出现,我们先来认识一下。

provider 就是模型的供货方DeepSeek 是一个 provider,OpenAI 是一个 provider,Anthropic 也是一个 provider

adapter 就是替你跟这家 provider 对话的那段代码。AI SDK 的做法是,每家 provider 都对应一个 adapter 包

一个 adapter 收一家 provider

import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

export function createDeepSeekAiSdkProvider() {
return createOpenAICompatible({
name: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY!,
baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com'
});
}

@ai-sdk/openai-compatible@ai-sdk/anthropic 这一类包,做的事不是生成文本。

它先替你把不同模型厂商接成 AI SDK 能认的一种统一模型接口。

adapter 的粒度,不是公司品牌,而是协议形状

这里我一开始也有点疑惑,DeepSeek 是一家独立的大模型公司,为什么要用一个叫 openai-compatible 的包来接?

DeepSeek 的 API 形状和 OpenAI 高度兼容,请求路径、请求体字段、流式事件格式基本一致,所有为 OpenAI 写的工具链几乎零代码就能接入。

这不是 DeepSeek 一家的选择,Moonshot、Groq、OpenRouter、Ollama 走的都是同一条路。

所以 @ai-sdk/openai-compatible 根本不在乎对面是 OpenAI 还是 DeepSeek,它只看协议形状对不对,给它配上 baseURLapiKey 就行。

Claude 是走另一条路的典型。

它有自己一套独立的 API 形状,路径、鉴权 header、事件类型、字段结构都和 OpenAI 不一样,所以要走 @ai-sdk/anthropic 这种专门适配。

这一层先替你接住的,不是页面,而是上游模型 API 的差异。


第二个节点:服务端怎么统一调模型和回流

模型接上之后,前端发一条消息过来,服务端总得先有一层把这次请求接住,拿到消息、调模型、再把结果回给前端

如果你用的是 Next.js,这层通常就在 app/api/.../route.ts 里,最常见就是那个 POST 函数。

后面如果我提到 route handler,你先把它理解成这层服务端入口就行。

原来这层里那一坨读流、缓冲、拼字符串的代码,现在基本就剩调一个函数加返回一个函数。

SDK 版服务端入口还剩什么

import { convertToModelMessages, streamText, type UIMessage } from 'ai';

export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();

const result = streamText({
model: deepseek.chatModel('deepseek-chat'),
messages: await convertToModelMessages(messages)
});

return result.toUIMessageStreamResponse();
}

这几行做了三件事:

convertToModelMessages 把前端消息格式翻成模型能认的格式;

streamText 调模型拿流式结果;

toUIMessageStreamResponse() 再把结果翻回前端能消费的事件流。

原来手写版那一大坨读流、缓冲、拼字符串的代码,基本都被这三个函数接走了。

手写版那层细节,SDK 是怎么处理的

mermaid-sdk.png

请求上游模型读流续字处理事件边界结束标记,这些原来散落在服务端入口这层的工作,

全部被 provider adapter 加 streamText 在内部接走。

最后那条「怎么把结果回给前端」的统一响应,则由 toUIMessageStreamResponse() 接走。

服务端轻下来的不是业务逻辑,是那些重复的流式协议处理。

前后端之间,其实还隔着一道翻译

convertToModelMessagestoUIMessageStreamResponse() 这两个函数,是一对翻译器

前端要渲染工具调用卡片、错误块、思考过程,所以 UIMessage 里带了一堆 parts

但模型只认扁平的 role + content

一端多一端少,中间就得有人翻。

convertToModelMessages把前端消息翻给模型toUIMessageStreamResponse()把模型流再翻回前端消息流

进去翻一次、出来也翻一次。

为什么回流不能只是纯文本

这里我一开始也没想清楚,手写版那套纯文本流明明能 work,toUIMessageStreamResponse() 为什么要做成一串带 type 字段的 JSON 事件?

因为前端要消费的不止是字。

AI 中途要显示「正在调用 search 工具」和工具结果卡片,要把灰色的「思考过程」和最终回答分开。

中途抛错要让 UI 识别这不是回答内容,结束时还要有一个明确信号解锁输入框。

如果流里只有字,浏览器根本没法区分哪段是思考、哪段是工具调用、哪段是错误、哪段是最终回答。

所以 SDK 把回流做成了结构化事件流,每个事件都带 type: "text-delta"type: "tool-call" 这种标签,前端看到什么 type 就走什么分支。

structured-event-stream.png

打开浏览器 DevTools 看一眼真实返回流会更直观:

{"type":"text-delta","id":"txt-0","delta":"Vercel"}
{"type":"text-delta","id":"txt-0","delta":" AI"}
{"type":"text-end","id":"txt-0"}
{"type":"finish-step"}
{"type":"finish","finishReason":"stop"}
[DONE]

模型 原始 SSE 里,文本增量走的是厂商自己的字段路径,结束信号也走的是厂商自己的结束格式。

AI SDK 回给前端的事件流里,文本增量统一变成 text-delta,结束信号统一变成 text-endfinish-stepfinish 这一类标签。

浏览器看到的已经是统一后的结果,不是模型厂商自己的原始协议。

前端处理渲染只要按 type 分发就行,不需要再认任何厂商的方言。

顺带留个钩子:messages 为什么是整段传上来的

你看这层服务端入口里 const { messages } = await req.json(),可能会以为前端传一句话、后端维护历史就行。

大模型 API 本身是无状态的

OpenAI、Claude 等大模型的 HTTP 接口其实都没有会话这个概念,每次请求都得把整段对话历史重新喂一次,模型拿到数组那一刻才「恢复」出上下文,生成完立刻丢掉。

所以这条链上的服务端入口每次都是全新的、无记忆的,它只是个转发层,状态落在前端的 messages 数组里。

对话越来越长之后,这个数组也会越来越大,token 吃不消了怎么办、记忆怎么维护,这是下一篇要讲的事。


第三个节点:前端怎么接住消息状态

服务端这层轻下来以后,前端这层也就没那么复杂了。

手写版前端状态,一开始通常就长这样

const [input, setInput] = useState('');
const [answer, setAnswer] = useState('');
const [isStreaming, setIsStreaming] = useState(false);

SDK 版就会变成:

const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({ api: '/api/ai-sdk-chat' })
});

useChat 暴露的不是 answer,是整个 messages 数组

这两段代码的差别,真不是少写几个 state 这么简单。

useChat 接住的是请求发送、流读取、消息追加、状态流转

你不用再自己读 response.body,也不用自己一边拼字符串,一边维护 isStreaming 什么时候开、什么时候关。

更关键的是它暴露给你的不是一个 answer,是一整个 messages 数组,每条消息长这样:

type UIMessage = {
id: string;
role: 'user' | 'assistant';
parts: Array<{ type: string; text?: string }>;
};

这套结构本来就是给整段对话准备的。

多轮、重生成、从中间分叉这些功能往上一加,全都顺势就成立。

举个例子,regenerate() 就是对 AI 最后一条回答不满意,重新生成一次

手写版里这件事通常不是再发一次请求这么简单,你得先砍掉最后一条 answer、把字符串状态倒推回消息数组、再补重发逻辑。

而 AI SDK 这边,messages 数组本来就记着每条消息,调一个方法的事。

所以真正少的不是代码行数,是你不用自己从零搭一套消息结构。

但页面最后怎么长,还是业务自己决定

它也没有替你把前端全部做完。

输入框 state、消息怎么渲染、错误 UI 怎么展示、按钮什么时候禁用、滚动什么时候到底,这些还是业务自己决定。

比如输入框的 input / setInput 就是业务自己维护的,useChat 不管:

<textarea value={input} onChange={(event) => setInput(event.target.value)} />
<button type="submit" disabled={status !== 'ready'}>
  {status === 'streaming' ? 'AI SDK 流式输出中...' : '调用 AI SDK 流式输出'}
</button>

useChat 接的是聊天状态,不是聊天页面长相。


如果你正从手写版迁过来,先改这三处

前面那条三层链路看懂之后,回到代码里第一刀该下在哪,其实顺序是固定的。

第一刀,先改 provider。

先别动前端,用 @ai-sdk/openai-compatible 或对应的 adapter 包把厂商 API 包一层,让后面 streamText 能直接调,上游差异先在这里隔离掉。

第二刀,再改服务端入口。

把手写读流、缓冲、组装响应那层换成 streamTexttoUIMessageStreamResponse()服务端重复的协议细节先拿掉

第三刀,最后改前端状态。

answer 这种单字符串状态换成 useChatmessages后面多轮、重生成、分叉、工具调用才有稳定地基

从手写版切到 AI SDK,第一刀先别改页面,先改协议层和状态层。

migration-three-cuts.png

改完这三刀,你会在两件事上感觉到变化。

一是原来服务端那坨 chunk 拼接、JSON 兜底、字段路径的代码,不用再看第二眼,协议那层不用碰了

二是后面再冒出「重生成」「从某条消息重开」这类需求的时候,你不用先回头重构 state,useChat 暴露的 messages 数组已经为这些需求打好地基了。

省下来的不只是代码行数,更像是脑子不用再同时装怎么读流怎么记对话这两件事。


现在来想想以下问题

Q1:服务端返回了一个 type: "tool-call" 的事件,但前端不知道怎么渲染。你觉得问题出在三层里的哪一层?

💡 provider adapter 负责接模型,streamText 负责调模型和回流,useChat 负责前端状态。tool-call 事件已经到了前端,说明前两层没问题。

Q2:如果你要给聊天页面加一个「从这条消息重新开始」的功能,手写版和 SDK 版各需要改什么?

💡 手写版的 answer 是一段字符串,你得先重构出消息数组才能定位到"这条消息"。SDK 版的 messages 数组里每条消息都有 id,天然支持这个操作。

Q3:现在 useChat 每次请求都把整个 messages 数组传给服务端,对话聊了 50 轮之后,你觉得会先撞到什么问题?

💡 这是下一篇的主题:对话越来越长,token 吃不消了,记忆怎么维护。


感谢您的阅读~🌹

我在微信公众号 前端Fusion 中也会持续同步更新关于 AI 与前端开发的相关文章,欢迎大家关注,一起交流学习。

分享底图_压缩.png

SBTI 测试挤崩服务器:一个程序员视角的技术复盘

2026年4月10日 14:01

昨晚你的朋友圈,是不是也被"尤物""吗喽""愤世者"刷屏了?

4月9日晚,一个叫 SBTI 的人格测试突然引爆社交网络。用户蜂拥而至,网站直接崩了——页面打不开、链接失效,大家只能靠截图"云测试"。作者深夜紧急发布新链接,称"做了略微修改,应该不会再崩了"。

作为一个技术人,看到"网站崩了"和"略微修改就不崩了"这两句话,我的职业病就犯了。今天我们不聊测试准不准,只聊:这背后到底发生了什么?如果是你来做,怎么才能扛住这波流量?

一、崩溃现场还原:一个经典的"雷群效应"

SBTI 测试的崩溃,是教科书级别的 Thundering Herd(雷群效应)案例。

简单说就是:一个原本只为"劝朋友戒酒"而做的小项目,突然被几百万人同时访问。这就好比你开了一家只有两张桌子的面馆,突然被美食博主推荐,门口排了两公里的队。

根据公开信息推测,SBTI 最初大概率是这样的架构:

用户浏览器 → 单台服务器(前端 + 后端 + 数据库 all-in-one

这种架构在日常几百、几千 PV 的场景下完全够用。但当朋友圈裂变式传播启动后,并发量可能在几分钟内从个位数飙升到数万甚至数十万级别。单机扛不住,结果就是:

  • 连接池耗尽:服务器能同时处理的请求数是有限的,超出后新请求直接被拒绝
  • 带宽打满:测试页面包含图片、样式、脚本,每个用户加载一次就消耗几百 KB 到几 MB 的带宽
  • 如果有后端逻辑:数据库连接数爆满,CPU 被打满,响应时间从毫秒级飙升到超时

二、"略微修改"背后的技术真相

作者说"做了略微修改,应该不会再崩了"。这句话信息量很大。

对于一个测试类 H5 应用,最高效的"略微修改"大概率是以下几种操作之一(或组合):

方案 A:纯静态化 + CDN 分发

测试类应用的核心逻辑其实很简单:展示题目 → 用户选择 → 前端计算结果 → 展示结果页。整个过程完全可以在浏览器端完成,不需要后端服务器参与。

之前:用户 → 源站服务器(动态渲染)
之后:用户 → CDN 边缘节点(静态资源)→ 前端 JS 本地计算结果

把所有页面打包成纯静态文件(HTML + CSS + JS + 图片),扔到 CDN 上。CDN 在全国有几百个边缘节点,用户访问时会自动路由到最近的节点。这样源站压力几乎为零,理论上可以承载千万级并发。

方案 B:更换托管平台

从自建服务器迁移到 Vercel、Cloudflare Pages、Netlify 等现代静态托管平台。这些平台天然具备全球 CDN 分发能力,部署一个静态站点只需要几分钟。

方案 C:Serverless 化

如果测试逻辑中确实有需要后端参与的部分(比如 AI 生成结果文案),可以将后端逻辑迁移到 Serverless 函数(如 AWS Lambda、阿里云函数计算)。Serverless 的核心优势是自动弹性伸缩——流量来了自动扩容,流量走了自动缩容,按实际调用次数计费。

三、如果让你从零设计,架构应该长什么样?

假设你现在要做一个类似 SBTI 的病毒式传播测试应用,并且预期它可能会爆火,推荐的架构如下:

┌─────────────────────────────────────────────────┐
│                    用户浏览器                      │
│  ┌───────────┐  ┌──────────┐  ┌───────────────┐  │
│  │ 答题引擎   │  │ 计分逻辑  │  │ 结果图片生成   │  │
│  │ (纯前端)   │  │ (纯前端)  │  │ (Canvas/SVG)  │  │
│  └───────────┘  └──────────┘  └───────────────┘  │
└──────────────────────┬──────────────────────────┘
                       │ 静态资源请求
                       ▼
              ┌─────────────────┐
              │   CDN 边缘节点    │
              │  (全国 300+ 节点) │
              └────────┬────────┘
                       │ 回源(极少触发)
                       ▼
              ┌─────────────────┐
              │  对象存储 (OSS)   │
              │  HTML/CSS/JS/图片 │
              └─────────────────┘

核心设计原则:

  1. 计算下沉到客户端:题目数据、计分逻辑、结果映射全部内嵌在前端代码中,浏览器本地完成所有计算,服务端零压力
  2. 资源全量 CDN 化:所有静态资源通过 CDN 分发,用户就近访问,首屏加载时间控制在 1-2 秒内
  3. 结果图片客户端生成:使用 Canvas API 或 html2canvas 在用户浏览器中直接生成分享图片,避免服务端图片渲染的性能瓶颈
  4. 零后端依赖:整个应用不需要数据库、不需要后端 API,运维成本趋近于零

四、病毒传播的技术引擎:分享链路优化

SBTI 能刷屏朋友圈,除了内容本身的娱乐性,分享链路的技术设计也至关重要。

微信分享卡片优化

// 微信 JS-SDK 分享配置
wx.updateAppMessageShareData({
  title: '我的SBTI人格是【尤物】,你是什么?',  // 动态标题,包含测试结果
  desc: 'MBTI已经过时了,来测测你的SBTI人格吧',
  link: 'https://example.com/sbti?from=share',   // 带来源追踪参数
  imgUrl: 'https://cdn.example.com/sbti-share.jpg' // 高辨识度的分享缩略图
})

关键技术点:

  • 动态分享标题:将用户的测试结果嵌入分享标题,制造好奇心驱动的点击欲望
  • 结果图片生成:用 Canvas 将测试结果渲染为一张精美的图片,方便用户保存并发到朋友圈
  • 短链接 + UTM 追踪:通过 URL 参数追踪传播路径,了解哪个渠道带来的流量最大

分享图片的客户端生成方案

import html2canvas from 'html2canvas';

async function generateShareImage(resultElement) {
  const canvas = await html2canvas(resultElement, {
    scale: 2,              // 2倍分辨率,保证清晰度
    useCORS: true,         // 允许跨域图片
    backgroundColor: null  // 透明背景
  });
  
  // 转为图片供用户长按保存
  const imgUrl = canvas.toDataURL('image/png');
  return imgUrl;
}

这个方案的好处是:图片在用户手机上生成,不需要服务端渲染,即使同时有 100 万人生成分享图,服务器也毫无压力。

五、AI 在其中扮演的角色

根据公开信息,SBTI 的人格描述内容使用了 AI 生成技术。这带来了一个有趣的架构选择:

方案一:预生成(推荐)

在开发阶段就用 AI 生成好所有人格类型的描述文案,作为静态数据打包到前端代码中。运行时不需要调用 AI 接口,零延迟、零成本。

// 预生成的结果数据,直接内嵌在前端代码中
const SBTI_RESULTS = {
  'ABCD': {
    title: '尤物',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/abcd.png'
  },
  'EFGH': {
    title: '吗喽',
    description: 'AI生成的人格描述文案...',
    image: '/assets/results/efgh.png'
  }
  // ... 其他类型
}

方案二:实时生成(不推荐用于病毒传播场景)

每次用户完成测试后实时调用 AI API 生成个性化描述。这种方案在流量暴增时会面临:API 调用成本飙升、响应延迟增大、API 限流等问题。

从 SBTI 的实际表现来看(崩溃后"略微修改"就恢复了),大概率采用的是方案一——AI 只在开发阶段参与内容生产,运行时是纯静态应用。

六、成本算一笔账

假设 SBTI 在爆火期间有 500 万次访问,每次访问加载约 2MB 资源:

方案 预估成本 能否扛住
单台云服务器(2核4G) ¥100/月,但会崩
CDN + 对象存储 流量费约 ¥500-1000
Vercel/Cloudflare Pages 免费版 ¥0(有带宽限制) ⚠️
Vercel Pro + CDN ¥150/月

一个纯静态的测试应用,即使面对百万级流量,CDN 方案的成本也就是一顿火锅钱。而如果用单机硬扛,服务器费用可能不高,但用户体验的损失是无法估量的——多少潜在的传播链路因为"页面打不开"而断裂了。

七、给开发者的 Takeaway

SBTI 事件给我们的启示:

  1. 永远为最好的情况做准备:如果你的产品有社交传播属性,请在架构设计时就考虑流量暴增的场景。CDN + 静态化的成本几乎为零,但收益是巨大的。

  2. 能在前端做的事,别放到后端:测试类应用的计算逻辑完全可以在浏览器端完成。每减少一次服务端请求,就多了一份稳定性。

  3. 分享体验就是增长引擎:结果图片的生成质量、分享卡片的文案设计,直接决定了传播系数。技术上要保证分享链路的流畅性。

  4. AI 是内容生产工具,不是运行时依赖:对于这类应用,AI 最适合在开发阶段批量生成内容,而不是在运行时实时调用。

  5. 小项目也值得好架构:SBTI 的作者最初只是想劝朋友戒酒,没想到会火。但如果一开始就用静态托管方案,根本不会有崩溃这回事。好的架构不一定复杂,但一定要匹配场景。


一个为劝朋友戒酒而生的测试,意外成为了一堂生动的高并发架构课。技术世界的浪漫,大概就是这样吧。


八、最后

文中技术分析基于公开信息推测,不代表 SBTI 实际技术实现。
如果你也在职场摸索成长路线,想了解更多内部跳槽、团队优化、技术实践和职场认知升级的经验,可以关注我的公众号:   [码农职场]
后续我会分享更多干货,帮助你在职场和技术上持续突破。

【前端基础】原生JS实现Tab栏切换--根据价格筛选商品

作者 vmiao
2026年4月10日 12:51

一、效果展示

图片展示:

屏幕截图 2026-04-10 115729.png 效果展示视频:(忽略清朝画质)

4月10日.gif

二、前置知识点

1.解构赋值:数组解构、对象解构
2.事件委托,利用冒泡原理为多个元素绑定事件
3.forEach()--遍历数组,循环;filter()筛选数组
4.箭头函数

三、练习素材(html+css)

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>第一天练习</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        li {
            list-style: none;
        }

        a {
            text-decoration: none;
        }

        .viewPort {
            width: 1024px;
            height: 780px;
            margin: 0 auto;
        }

        .list {
            margin-top: 30px;
            display: flex;
            width: 1024px;
            flex-wrap: wrap;
        }

        .item {
            width: 240px;
            margin-left: 10px;
            padding: 20px 30px;
            transition: all .5s;
            margin-bottom: 20px;
        }

        .item:nth-child(4n) {
            margin-left: 0;
        }

        .item:hover {
            box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
            transform: translate3d(0, -4px, 0);
            cursor: pointer;
        }

        .item img {
            width: 100%;
        }

        .item .name {
            font-size: 18px;
            margin-bottom: 10px;
            color: #666;
        }

        .item .price {
            font-size: 22px;
            color: firebrick;
        }

        .item .price::before {
            content: "¥";
            font-size: 14px;
        }

        .filter {
            display: flex;
            width: 990px;
            margin: 0 auto;
            padding: 50px 30px;
        }

        .filter a {
            padding: 10px 20px;
            background: #f5f5f5;
            color: #666;
            text-decoration: none;
            margin-right: 20px;
        }

        .filter a:active,
        .filter a:focus {
            background: #05943c;
            color: #fff;
        }
    </style>
</head>

<body>
    <div class="viewPort">
        <div class="filter">
            <a data-index="1" href="javascript:;">0-100元</a>
            <a data-index="2" href="javascript:;">100-300元</a>
            <a data-index="3" href="javascript:;">大于300元</a>
            <a href="javascript:;">全部区间</a>
        </div>
        <div class="list">
            <!-- <div class="item">
                <img src="" alt="">
                <p class="name"></p>
                <p class="price"></p>
            </div> -->
        </div>
    </div>
    <script>
        // 2. 初始化数据
        const goodsList = [
            {
                id: '4001172',
                name: '称心如意手摇咖啡磨豆机咖啡豆研磨机',
                price: '289.00',
                picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg',
            },
            {
                id: '4001594',
                name: '日式黑陶功夫茶组双侧把茶具礼盒装',
                price: '288.00',
                picture: 'https://yanxuan-item.nosdn.127.net/3346b7b92f9563c7a7e24c7ead883f18.jpg',
            },
            {
                id: '4001009',
                name: '竹制干泡茶盘正方形沥水茶台品茶盘',
                price: '109.00',
                picture: 'https://yanxuan-item.nosdn.127.net/2d942d6bc94f1e230763e1a5a3b379e1.png',
            },
            {
                id: '4001874',
                name: '古法温酒汝瓷酒具套装白酒杯莲花温酒器',
                price: '488.00',
                picture: 'https://yanxuan-item.nosdn.127.net/44e51622800e4fceb6bee8e616da85fd.png',
            },
            {
                id: '4001649',
                name: '大师监制龙泉青瓷茶叶罐',
                price: '139.00',
                picture: 'https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png',
            },
            {
                id: '3997185',
                name: '与众不同的口感汝瓷白酒杯套组1壶4杯',
                price: '108.00',
                picture: 'https://yanxuan-item.nosdn.127.net/8e21c794dfd3a4e8573273ddae50bce2.jpg',
            },
            {
                id: '3997403',
                name: '手工吹制更厚实白酒杯壶套装6壶6杯',
                price: '99.00',
                picture: 'https://yanxuan-item.nosdn.127.net/af2371a65f60bce152a61fc22745ff3f.jpg',
            },
            {
                id: '3998274',
                name: '德国百年工艺高端水晶玻璃红酒杯2支装',
                price: '139.00',
                picture: 'https://yanxuan-item.nosdn.127.net/8896b897b3ec6639bbd1134d66b9715c.jpg',
            },
        ]

四、功能JS模块

1.渲染函数的封装

function(arr){
    let str = "" //声明一个空字符串
    arr.forEach(item => {
        const {name,picture,price} = item //对象解构,快速批量的声明变量,缩减代码量
        str += `
            <div class="item">
                <img src=${picture} alt="">
                <p class="name">${name}</p>
                <p class="price">${price}</p>
            </div> 
        ` 
        //arr中有几个元素就渲染几个div,将数据填入 ${},动态渲染
    })
    document.queryselector(".list").innerHTML = str //将字符串放入.list的div盒子中
   }
   render(goodsList) //调用函数
    

2.绑定点击事件

    //事件委托,为父元素绑定点击事件
    document.queryselector("filter").addEventListener("click",e =>{
        const {tagName,dataset} = e.target //获取点击对象+对象解构
        let arr = goodsList //因为如果都不点击就显示全部元素,所以初始值将arr直接等于goodsList
        if(tagName === "A"){ //点击a标签时才触发,点击父元素空白区域不触发
            if (dataset.index === "1") {
                    arr = goodsList.filter(item => item.price > 0 && item.price <= 100)
                } else if (dataset.index === "2") {
                    arr = goodsList.filter(item => item.price >= 100 && item.price <= 300)
                } else if (dataset.index === "3") {
                    arr = goodsList.filter(item => item.price >= 300)
                }
                render(arr)
        }
    })

五、易错点

1.对象解构时,例如const {name,price} = item //这里是花括号,数组解构时是[]。并且对象解构时声名的变量名必须和数据中的属性名一致
2.函数不调用不执行,封装好函数后一定要调用执行
3.箭头函数省略原则:参数只有一个时,()可以省略;若函数中只有一句return语句时,{}和return都可以省略;!!!没有参数时,()一定不要省

装饰器:那个在代码里“贴标签”的黑魔法,到底有什么用?

作者 kyriewen
2026年4月10日 11:47

你有没有在Angular或NestJS里见过@Component@Injectable这种稀奇古怪的“@符号”?它们就像给代码贴的“便利贴”,背后却能自动帮你做一堆事情。今天我们就来揭开TypeScript装饰器的神秘面纱,看看这个“贴标签”魔法到底怎么用,以及为什么它能让你少写几千行重复代码。

前言

想象你去餐厅吃饭,你在菜单上贴了个标签“@少油”,厨房看到后自动给你少放油。你又贴个“@加辣”,厨房又自动加辣。你只需要贴标签,厨房负责执行。

这就是装饰器。它是一种特殊的声明,可以附加在类、方法、属性、参数上,用来修改或增强它们的行为。你不用手动调用什么函数,只要贴上“标签”,背后的逻辑就会自动生效。

一、装饰器长啥样?先看个例子

在TypeScript里,装饰器以@expression的形式出现,expression是一个函数,会在运行时被调用。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log('调用了方法:', propertyKey);
}

class Person {
  @log
  sayHello() {
    console.log('Hello');
  }
}

const p = new Person();
p.sayHello();
// 输出:
// 调用了方法: sayHello
// Hello

你什么都没改,只是加了个@log,每次调用sayHello就会自动打印日志。这就是装饰器的魅力。

二、启用装饰器:别急,先开个开关

TypeScript的装饰器目前是实验性特性,需要在tsconfig.json里开启:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true  // 可选,用于反射元数据
  }
}

三、装饰器的四种类型

装饰器可以贴在四个地方:类、方法、访问器/属性、参数。每种都有不同的参数签名。

1. 类装饰器

作用在类上,通常用来修改或替换类的定义。

function addTimestamp<T extends { new (...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    timestamp = new Date();
  };
}

@addTimestamp
class User {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

const user = new User('张三');
console.log(user); // User { name: '张三', timestamp: 2025-04-10... }

类装饰器接收一个参数:类的构造函数。你可以返回一个新类替换它,或者直接修改原型。

2. 方法装饰器

最常用,可以拦截、修改、替换方法。

function measure(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    const start = performance.now();
    const result = original.apply(this, args);
    const end = performance.now();
    console.log(`${propertyKey} 耗时 ${end - start}ms`);
    return result;
  };
  return descriptor;
}

class Calculator {
  @measure
  add(a: number, b: number) {
    return a + b;
  }
}

参数:

  • target:类的原型(静态方法则是构造函数)
  • propertyKey:方法名
  • descriptor:属性描述符(可以修改value、writable等)

3. 属性装饰器

作用在属性上,通常用于配合元数据做依赖注入或验证。

function format(formatStr: string) {
  return function(target: any, propertyKey: string) {
    let value: string;
    const getter = function() {
      return value;
    };
    const setter = function(newVal: string) {
      value = formatStr.replace('%s', newVal);
    };
    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
      enumerable: true,
      configurable: true
    });
  };
}

class Greeting {
  @format('Hello, %s')
  name: string;
}

属性装饰器只能拿到目标类和属性名,不能直接修改属性值,但可以通过Object.defineProperty替换getter/setter。

4. 参数装饰器

作用在函数参数上,常用于依赖注入框架(比如Angular)。

function paramLogger(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`参数位置 ${parameterIndex} 被装饰了`);
}

class UserService {
  getUser(@paramLogger id: number) {
    return { id };
  }
}

参数装饰器很少单独用,通常配合类装饰器或方法装饰器收集元数据。

四、装饰器工厂:给装饰器传参数

你看到@log@measure这些是不带参数的。如果想让装饰器接受配置,需要再包一层函数:

function log(prefix: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const original = descriptor.value;
    descriptor.value = function(...args: any[]) {
      console.log(`${prefix} 调用 ${propertyKey}`);
      return original.apply(this, args);
    };
  };
}

class Test {
  @log('DEBUG')
  doSomething() {
    console.log('执行');
  }
}

这就是装饰器工厂:外层函数接收参数,内层函数是真正的装饰器。

五、多个装饰器:从下往上,从右往左

当你在同一个目标上使用多个装饰器时,它们的执行顺序是:先执行靠近目标的(从下往上),再执行外层的

@classDecoratorA
@classDecoratorB
class MyClass {}

执行顺序:classDecoratorB 先执行,然后 classDecoratorA

方法上的装饰器类似:先执行参数装饰器,再执行方法装饰器,最后是类装饰器(但方法装饰器本身的调用顺序是从下往上)。

六、实战:用装饰器实现权限校验

假设你要写一个类,某些方法只有管理员能调用。你可以用装饰器优雅地实现:

function adminOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args: any[]) {
    if (!this.isAdmin) {
      throw new Error('无权限,需要管理员角色');
    }
    return original.apply(this, args);
  };
}

class UserController {
  isAdmin = false;

  @adminOnly
  deleteUser(id: number) {
    console.log(`删除用户 ${id}`);
  }
}

const ctrl = new UserController();
ctrl.deleteUser(1); // 报错:无权限
ctrl.isAdmin = true;
ctrl.deleteUser(1); // 成功

看,你只需要在需要权限的方法上贴个@adminOnly,逻辑自动注入。

七、装饰器的实际应用场景

  • 日志记录:自动打印方法入参、返回值、耗时。
  • 权限校验:检查当前用户角色。
  • 数据验证:验证方法参数格式。
  • 依赖注入:Angular、NestJS 里大量使用。
  • 性能监控:自动记录方法执行时间。
  • 重试机制:方法失败后自动重试。

八、注意事项与坑点

  1. 装饰器目前是实验特性,虽然Angular、NestJS等框架广泛使用,但未来ECMAScript标准可能会有所变化。
  2. 不能用在普通JS文件,必须在TS或Babel中启用。
  3. 属性装饰器不能直接修改属性值,需要通过Object.defineProperty替换getter/setter。
  4. 装饰器在类定义时执行,而不是实例化时。这意味着你不能依赖实例属性(比如this.isAdmin)来做静态分析,但可以在返回的函数中延迟读取。

九、总结:装饰器就像“代码贴纸”

  • 装饰器是给类、方法、属性、参数贴的“标签”。
  • 标签背后的函数会在运行时自动执行,修改目标的行为。
  • 装饰器工厂可以传参,实现定制化。
  • 多个装饰器从下往上执行。
  • 常见用途:日志、权限、验证、注入。

学会装饰器,你就能写出更声明式、更优雅的代码。很多框架的魔法背后,其实就是这些小小的“@”符号。

如果你觉得今天的“便利贴”魔法够神奇,点个赞让更多人看到。明天我们将开启浏览器渲染原理之旅,从输入URL到页面显示,中间到底发生了什么?我们明天见!

别再被 `npx` 骗了:Debug 纪实 —— 为什么总是找不到文件?

2026年4月10日 11:45

做全栈开发,最让人抓狂的往往不是复杂的业务逻辑,而是各种匪夷所思的 “环境玄学”

  • “为什么教学视频里敲 npx xxx 秒开,我一敲就报错?”
  • “为什么我昨天在这台电脑上敲就没事,今天怎么突然就不行了?”
  • “按照控制台弹出的方案重试了 3 次,为什么一行能在 Windows 跑通的都没有?”

今天,我们就以开发用到的 Inngest CLI 为例(同样适用于 Prisma, esbuild, sharp 等工具),彻底扒开前端包管理器的底层黑盒,讲透这个恶心无数开发者的 Binary not found 现象。


💥 案发现场

当你在本地输入 npx inngest-cli@latest dev 时,满心欢喜地等待面板启动,结果迎面砸来这样一段报错:

Error: Inngest CLI binary not found.
This happened because install scripts were skipped.
To fix this, use the method most appropriate for your setup:
  NPM_CONFIG_CACHE=$(mktemp -d) npx --ignore-scripts=false inngest-cli@latest
  ...

你尝试复制了报错提示里的命令,然后发现它在 Windows 的 PowerShell 里连语法都不对! 这是为什么?


🕵️‍♂️ 剥开黑盒探寻本质:为什么找不到肉身?

这个报错并不是说你断网了没装上包,而是说你装下来的包**“少了灵魂”**。

1. 挂羊头卖狗肉的 NPM 包装戏法

现代的开发工具链(如 Inngest、esbuild、Prisma 等)由于对性能有极致要求,它们底层的引擎绝大多数是用 Go、C++ 或 Rust 写的。 为了能兼容前端庞大的 npm 生态,开发者通常会在 npm 仓库里发一个纯粹由 JS 构成的 “空壳子”

它的真实运作机制是: 触发安装 -> 下载 JS 空壳 -> 触发 postinstall 钩子脚本 -> 脚本自动从 Github Releases 拉取对应系统(Win/Mac/Linux)的 .exe 可执行文件。

一旦这个 postinstall 脚本因为任何原因(网络超时、没有权限)没有跑成功,你的包里就只剩下一个没用的 JS 空壳。这就叫 Binary not found

2. 拦路虎:pnpm v10 的“安全铁腕”

你可能会问:“我的网络有魔法代理,为什么还会失败?” 真相隐藏在你的包管理器里。如果你升级到了 pnpm v10,由于它引入了极其严格的“受信任依赖”机制,默认会悄悄拦截一切第三方包在后台执行构建脚本(postinstall)的行为

你的命令行里大概会有这样一行一闪而过的高大上的警告:

Ignored build scripts: inngest-cli@1.16.1. Run "pnpm approve-builds" to pick ...

是的,是 pnpm 觉得这个包不安全,亲手把下载 .exe 的途径给掐断了。

3. NPX 的“就近连坐”病毒(解释时灵时不灵)

这是最魔幻的一点:为什么昨天能行,今天装完反而坏了?

  • 当你没安装时(昨天):运行 npx 时,它去自己干净的全局临时目录下载了一个包,刚好没受困于安全拦截,顺利拿到了二进制文件,成功运行。
  • 当你在本地项目里安装了它但被拦截时(今天):你的项目 node_modules 里多了一个“没有二进制文件的空壳包”。
  • 致命的偷懒机制:当你再次敲击 npx inngest-cli 时,npx 会自作聪明地优先使用本地项目中已有的坏包,而不是去全局深究。

这就造成了:只要你的项目里混进了一个“太监版”的依赖,无论你敲多少次全局 npx,它都会被就近传染,当场暴毙。


🛠️ 解法:做防弹的工程底座

搞懂了原理,我们就绝不能像“脚本小子”一样,每次报错就去删除 %LOCALAPPDATA%\npm-cache\_npx 缓存。在正规的全栈商业级项目中,所有基建都必须是绝对受控且确定的。

彻底杜绝玄学的标准动作:将隐式全局依赖,转变为显式本地依赖。

Step 1: 签署白名单 (pnpm.onlyBuiltDependencies)

不要让 pnpm 盲猜,直接在你的 package.json 中明确发给 Inngest 发“放行条”:

{
  "pnpm": {
    "onlyBuiltDependencies": [
      "inngest-cli",
      "prisma",
      "esbuild",
      "sharp"
    ]
  }
}

🔥 Tips: 另一个快捷写法是在终端执行 pnpm approve-builds --save-bundle,它会自动把被拦截的包扫进信任名单。

Step 2: 固化到开发依赖

将不靠谱的 npx 游击战术转编为正规军:

# 保证当前终端顺畅访问 Github 的前提下
pnpm add -D inngest-cli

这时候你再看日志,必定能看到真正的 .exe 安稳落地。

Step 3: 固定项目启动快捷键

打开 package.jsonscripts

"scripts": {
  "dev:inngest": "inngest dev"
}

以后只需优雅地执行 pnpm run dev:inngest,把复杂的事情彻底封装在项目内部。不管换谁接手、换什么电脑拉下代码,都不再需要承受你昨天吃过的苦!


🎯 总结与认知升级

全栈开发往往就是在和这些看似无聊的“基建脏活”抗争。当你能够把“这破电脑怎么又抽风了”,转变为“哦,这显然是 pnpm 包提取钩子被跳过导致的本地模块污染”,你的水平就已经跟初级搬砖工拉开了真正的身位。

下次如果有人对你说“这机器跑不起来,但我本地没问题”,记得用这套理论降维打击他。👨‍💻

Android 项目生成jks签名文件

作者 Indevelbbbb
2026年4月10日 11:39

cmd命令行输入:flutter doctor -v

image.png 进入红框中地址

image.png 运行该文件,并复制该地址配置环境变量系统变量path

image.png

屏幕截图 2026-04-10 112446.png 打开Android studio Terminal 运行

keytool -genkey -v -keystore /D:/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

/D:/key.jks 为你想放置的jks位置和.jks文件名。位置可先随意放置后期移动到项目/Android/app目录下。

运行后先输入两次密码(看不见输入内容直接输入),然后随便输入姓名地址等。输入完成后便会生成。jks文件。 将.jks文件移动到/Android/app目录。

最后配置key.properties文件 image.png

CSS Grid 布局参考表

作者 小霍同学
2026年4月10日 11:04

CSS Grid 布局参考表

容器属性(作用在父元素)

属性 可选值 默认值 说明
display grid inline-grid 定义块级或行内级网格容器
grid-template-columns 长度 / 百分比 / fr / auto / minmax() / repeat() none 定义列宽(数量和尺寸),如 200px 1fr 2fr
grid-template-rows 同上 none 定义行高(数量和尺寸),如 100px auto 1fr
grid-template-areas 字符串组成的矩阵,用 . 表示空单元格 none 通过命名区域定义布局,如 "header header" "sidebar main"
gap row-gap column-gap 长度值(20px1rem 0 行间距 / 列间距。gap 为简写,先行后列
justify-items start / end / center / stretch stretch 所有项目在单元格内的水平对齐(行轴)
align-items start / end / center / stretch stretch 所有项目在单元格内的垂直对齐(列轴)
place-items <align-items> <justify-items> stretch stretch 简写,先垂直后水平,如 center center
justify-content start / end / center / stretch / space-between / space-around / space-evenly start 整个网格在容器内的水平对齐(网格总宽 < 容器时生效)
align-content 同上 start 整个网格在容器内的垂直对齐(网格总高 < 容器时生效)
place-content <align-content> <justify-content> start start 简写,先垂直后水平
grid-auto-columns 长度 / 百分比 / fr / minmax() auto 隐式创建的列的尺寸
grid-auto-rows 同上 auto 隐式创建的行的尺寸
grid-auto-flow row / column / row dense / column dense row 自动布局的排列方向及是否“紧密”填充

项目属性(作用在直接子元素)

属性 可选值 默认值 说明
grid-column start / end span n start / span n auto / auto 项目占用的列范围,例:2 / 4span 22 / span 2
grid-row 同上 auto / auto 项目占用的行范围
grid-area <row-start> / <col-start> / <row-end> / <col-end> 或区域名称 auto 位置简写或引用 grid-template-areas 定义的名称
justify-self start / end / center / stretch stretch 单个项目在单元格内的水平对齐(覆盖容器 justify-items
align-self start / end / center / stretch stretch 单个项目在单元格内的垂直对齐(覆盖容器 align-items
place-self <align-self> <justify-self> stretch stretch 简写,先垂直后水平,如 center end

常用函数与关键字速查

函数/关键字 语法示例 说明
fr 1fr2fr 剩余空间分数单位,类似 Flexbox 的 flex-grow
minmax() minmax(100px, 1fr) 定义尺寸范围(最小,最大),用于响应式轨道
repeat() repeat(3, 1fr) 简化重复轨道定义,可嵌套 minmax()
auto-fill repeat(auto-fill, 200px) 尽可能多地放置轨道(不拉伸),空轨道保留空间
auto-fit repeat(auto-fit, 200px) 放置轨道后拉伸现有轨道填满容器,空轨道折叠
span grid-column: span 2 跨越指定数量的轨道,不指定起始线

对齐方式速查(区分容器级与项目级)

作用对象 水平对齐属性 垂直对齐属性 简写
所有项目在单元格内 justify-items align-items place-items
单个项目在单元格内 justify-self align-self place-self
整个网格在容器内 justify-content align-content place-content

关键点

  • justify-* 控制行轴(水平),align-* 控制列轴(垂直)。
  • *-items / *-self 控制项目在单元格内的位置,*-content 控制整个网格在容器内的位置。

隐式网格 vs 显式网格

类型 定义属性 说明
显式网格 grid-template-rows grid-template-columns 开发者明确指定的行/列轨道
隐式网格 grid-auto-rows grid-auto-columns 当项目超出显式范围时自动生成的额外轨道,尺寸由 grid-auto-* 决定

触发隐式网格的常见情况:

  • 使用 grid-row: 4 但显式只定义了 3 行。
  • 自动布局的项目数量多于显式网格单元格数(配合 grid-auto-flow)。

浏览器兼容性简表

浏览器 最低支持版本 备注
Chrome 57+ 完全支持
Firefox 52+ 完全支持
Safari 10.1+ 完全支持
Edge 16+ 完全支持
IE 10 / 11 部分支持(旧语法 -ms- 不推荐用于现代项目,存在大量 bug

Mac VS Code/Cursor 终端 pnpm 命令找不到的解决方案

2026年4月10日 11:02

Mac VS Code/Cursor 终端 pnpm 命令找不到解决方案

最近在 Mac 上搭建 Vue3 开发环境,遇到了一个问题:系统自带的 zsh 终端能正常运行 pnpm 命令,但 VS Code 和 Cursor 编辑器的内置终端,始终提示「pnpm: command not found」

期间尝试了网上多种方案,甚至按照教程修改了编辑器终端配置,却始终无效,最后误打误撞通过 nvm 切换 Node 版本,成功让 pnpm 命令在所有终端生效。结合自己的踩坑经历,整理出这篇完整解决方案,帮有同样问题的朋友少走弯路。

一、问题复现(精准对号入座)

  1. 我是通过 brow install pnpm命令安装的 pnpm(系统终端运行 pnpm -v 能正常显示版本号);
  2. 打开 VS Code 或 Cursor 编辑器,内置终端输入pnpm -v,直接报错:bash: pnpm: command not found
  3. 系统终端(zsh)一切正常,编辑器终端无论怎么重启、重新打开,问题始终存在;
  4. 前期尝试修改编辑器终端配置、执行 source ~/.zshrc 等操作,均未解决问题。

二、无效尝试(对于某些小伙伴可能会有效)

1. 执行 source 命令临时加载环境

在编辑器终端输入 source ~/.zshrc,执行后还是不行。

2. 修改 VS Code/Cursor 终端配置

按照教程修改了编辑器的 settings.json,将内置终端默认设置为 zsh,并添加环境变量加载命令,配置如下(亲测无效):

{
    "terminal.integrated.profiles.osx": {
        "zsh": {
            "path": "/bin/zsh",
            "args": ["-l", "-c", "source ~/.zshrc && exec zsh"]
        }
    },
    "terminal.integrated.defaultProfile.osx": "zsh"
}

修改后重启编辑器,终端依然无法识别 pnpm 命令。

3. 配置环境变量到 .zprofile

执行命令 echo 'export PATH="$HOME/.nvm/versions/node/$(node -v | tr -d 'v')/bin:$PATH"' >> ~/.zprofile,将 nvm 和 pnpm 路径写入 .zprofile,执行 source ~/.zprofile 后,编辑器终端仍无法识别。

三、最终解决方案(nvm 切换 Node 版本,一键生效)

头大,所有常规方案都尝试了,后来我想是不是版本对不上导致的:自己是用 nvm 管理 Node 版本的,当前使用的 Node 版本可能和 pnpm 安装时的 Node 版本不匹配,于是尝试切换 Node 版本,万万没想到还真是【手动狗头】!

具体步骤:

1. 查看已安装的 Node 版本

在系统终端(zsh)输入以下命令,查看本地通过 nvm 安装的所有 Node 版本:

nvm ls

会显示类似如下内容(* 表示当前正在使用的版本):

       v16.20.2
->     v20.10.2

2. 切换到其他已安装的 Node 版本

我当前使用的是 v16.20.2,尝试切换到 v20.20.2(或其他已安装的版本),命令如下:

nvm use 20.20.2

切换成功后,终端会提示:Now using node v20.20.2 (npm v10.8.2)

3. 重新打开编辑器或直接在终端输入命令,测试 pnpm

关闭 VS Code/Cursor 所有窗口,重新打开,内置终端输入:

pnpm -v

此时,终端会正常显示 pnpm 版本号,pnpm 命令彻底生效,问题解决!

JSX 剥茧抽丝:从语法糖到 JavaScript 对象的演变之旅

2026年4月10日 10:45

JSX 到底是什么?

它不是字符串,也不是 HTML,而是 React.createElement(或新的 JSX Transform)的语法糖。 在运行过程中会通过jsxProd函数转为createElement实现的js对象

编译流程:Babel 的“翻译艺术”

既然浏览器看不懂 JSX,我们就需要一个翻译官。这个翻译官就是 Babel

当你写下这段代码:

JavaScript

function Welcome() {
  return (
    <div className='container'>
      <p>Hello World</p>
    </div>
  )
}

Babel 会在后台把它“翻译”成这样:

JavaScript

import { jsx as _jsx } from "react/jsx-runtime";

function Welcome() {
  return _jsx("div", {
    className: 'container',
    children: _jsx("p", { children: "Hello World" })
  });
}

babel转译的结果: image.png

为什么我们离不开 JSX?

既然底层都是 JS 对象,我们为什么不直接写对象,非要用 JSX 呢?

1. 效率:从“搬砖”到“画图”

如果没有 JSX,你需要手动写嵌套极深的函数调用。想象一下,写一个 10 层的列表,代码会变成一团乱麻。JSX 让我们用声明式的方式写代码——你只需要告诉 React“我想要长成这样的 UI”,剩下的翻译工作交给工具。

2. 可读性:一眼看透 UI 结构

JSX 的树状结构与最终生成的页面结构完全一致。这种“所见即所得”的快感,让团队协作和代码维护变得异常轻松。

3. 安全性:自带“防弹衣”

JSX 在渲染前会默认转义所有输入。这意味着如果你尝试在内容里注入一段恶意的 <script> 脚本,JSX 会把它当成普通文本处理,从而天然防御了 XSS(跨站脚本)攻击


结语

JSX 是连接“开发者友好”与“机器高效”的桥梁。它用 HTML 的皮囊包裹着 JavaScript 的灵魂。下次当你写下 <Component /> 时,记住你其实是在写一份精密的 JS 说明书,而 React 正在幕后为你搭建那座华丽的数字大厦。

[前端]可折叠容器组件、信息展示卡片组件

作者 焰火1999
2026年4月10日 10:36

分享两个Web前端组件,均为可折叠的内容展示组件,基于Vue3、ElementPlus框架。

可折叠容器组件

组件Vue源码

<!--
  * 可折叠容器组件
  * 
  * Author: GFire
  * Date: 2026/03/23
-->
<template>
  <div class="box-container">
    <div class="title" @click="isCollapse = !isCollapse">
      <el-icon style="margin: 0 5px">
        <ArrowUpBold v-if="isCollapse" />
        <ArrowDownBold v-else />
      </el-icon>
      {{ title }}
    </div>
    <el-collapse-transition>
      <div v-show="!isCollapse" :class="contentContainerClass || 'content-container'">
        <!-- 内容区域默认插槽 -->
        <slot></slot>
      </div>
    </el-collapse-transition>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * 标题文字
     */
    title?: string;
    /**
     * 内容区域样式class名(用于自定义样式)
     */
    contentContainerClass?: string;
  }>(),
  {
    title: '',
  },
);

// 是否折叠
const isCollapse = ref(false);
</script>

<style scoped>
.box-container {
  border: 1px solid #ececef;
  border-radius: 4px;
}

.title {
  background-color: #f7f8fb;
  padding: 5px;
  border-bottom: 1px solid #ececef;
  cursor: pointer;
}

.content-container {
  padding: 10px;
}
</style>

组件使用示例

使用代码:

<CollapseBox title="张雪820RR资料" style="width: 600px" content-container-class="collapseContent">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</CollapseBox>

<style scoped>
/* 自定义内容区域的样式 */
:deep(.collapseContent) {
  padding: 15px;
}
</style>

解释:

  1. title为标题
  2. content-container-class为内容区域样式class名(用于自定义样式)
  3. 内容区域默认展开,点击标题区域切换折叠/展开状态。

显示效果:

image.png

折叠效果:

image.png

信息展示卡片组件

组件Vue源码

<!--
  * 基础组件:信息展示卡片
  * 
  * Author: GFire
  * Date: 2024/10/25
-->
<template>
  <el-card shadow="hover">
    <template #header>
      <div class="header">
        <div class="title" :style="getTitleStyle()">
          {{ props.title }}
        </div>
        <el-button @click="visible = !visible" :icon="visible ? 'Minus' : 'Plus'" text type="primary" size="small"></el-button>
      </div>
    </template>
    <!-- 卡片内容插槽 -->
    <el-collapse-transition>
      <div v-show="visible">
        <slot></slot>
      </div>
    </el-collapse-transition>
  </el-card>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import type { CSSProperties } from 'vue';

const props = withDefaults(
  defineProps<{
    title: string;
    titleSize?: number | string; // 标题字体大小, 传数字时单位为px, 传字符串时原样赋值
  }>(),
  {
    titleSize: 16,
  },
);

const visible = ref(true);

function getTitleStyle(): CSSProperties {
  return {
    fontSize: getTitleSize(),
  };
}

function getTitleSize() {
  const size = props.titleSize;
  if (typeof size === 'number') {
    return size + 'px';
  } else {
    return size;
  }
}
</script>

<style scoped>
:deep(.el-card__header) {
  padding: 10px;
}

:deep(.el-card__body) {
  padding: 12px;
}

.header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.title {
  border-left: 7px solid var(--el-color-primary);
  padding: 3px 10px;
  font-weight: bold;
}
</style>

组件使用示例

使用代码:

<InfoCard title="张雪820RR资料" style="width: 600px">
  <div>
    张雪820RR搭载张雪机车自研的820cc直列三缸发动机,最大功率99kW,最大扭矩80N·m,零百加速仅需2.81秒,极速高达280km/h。结合其43800元的售价来看,性价比简直爆棚,毕竟价格贵了快三倍的杜卡迪V2也没有如此炸裂的性能。不过张雪820RR最硬核的并非参数,而是背后的技术实力。
  </div>
</InfoCard>

显示效果:

image.png

点击右上角的“-”按钮可以折叠,效果:

image.png

再点击右上角的“+”按钮则展开

React Dev Inspector 架构深度解析:从浏览器到 IDE 的链路之旅

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

引言:点击页面元素,IDE 自动打开源码——这背后发生了什么?

想象一下:你在浏览器里看到一个 React 组件,按下 Ctrl+Shift+Command+C,鼠标悬停在元素上,点击一下——VSCode 自动打开了对应组件的源码文件,光标精准定位到组件定义处。这个看似简单的功能,背后涉及编译时代码转换运行时 Fiber 遍历跨层数据传递服务端进程调用等多个技术环节。

本文将沿着一次完整的"inspect"操作,深入剖析 react-dev-inspector 的架构设计与实现原理。


第一章:编译时准备——Babel Plugin 如何埋入源码坐标

1.1 JSX 元素的"坐标标记"

react-dev-inspector 的第一步发生在编译阶段。@react-dev-inspector/babel-plugin 会在 JSX 元素上注入 data-inspector-* 属性,记录该元素在源码中的位置信息。

// packages/babel-plugin/src/visitor.ts
const doJSXOpeningElement: NodeHandler<
  JSXOpeningElement,
  { relativePath: string }
> = (node, option) => {
  const { stop } = doJSXPathName(node.name)
  if (stop) return { stop }

  const { relativePath } = option
  const line = node.loc?.start.line
  const column = node.loc?.start.column

  const lineAttr: JSXAttribute | null = isNil(line)
    ? null
    : jsxAttribute(
      jsxIdentifier('data-inspector-line'),
      stringLiteral(line.toString()),
    )
  // ... columnAttr, relativePathAttr

  const attributes = [lineAttr, columnAttr, relativePathAttr] as JSXAttribute[]
  if (attributes.every(Boolean)) {
    node.attributes.unshift(...attributes)
  }
  return { result: node }
}

Why this design?

在编译时注入坐标信息是最可靠的方式。因为:

  1. 编译时拥有完整的 AST 和 sourcemap 信息
  2. 运行时可以通过 DOM 元素的 props 直接读取,无需额外计算
  3. 相比 @babel/plugin-transform-react-jsx-source 注入的 _debugSource,这种方式提供了相对路径,更适合 monorepo 场景

What if alternative?

如果不使用 Babel Plugin,也可以依赖 React 内置的 _debugSource(由 @babel/plugin-transform-react-jsx-source 提供),但它只包含绝对路径。在服务端需要额外的路径映射逻辑来处理不同操作系统和项目结构。

1.2 数据流:编译时 → 运行时

graph LR
    A[源码 JSX] --> B[Babel Plugin]
    B --> C{是否 Fragment}
    C -->|是| D[跳过处理]
    C -->|否| E[注入 data-inspector-*]
    E --> F[编译后代码]
    F --> G[浏览器运行]
    G --> H[DOM 元素携带坐标属性]

第二章:运行时核心——Inspector 组件的状态管理

2.1 受控与非受控的双模式设计

Inspector 组件支持两种使用模式:

// packages/inspector/src/Inspector/hooks/use-controlled-active.ts
export const useControlledActive = ({
  controlledActive,
  onActiveChange,
  onActivate,
  onDeactivate,
  disable,
}: {
  controlledActive?: boolean;
  onActiveChange?: (active: boolean) => void;
  onActivate?: () => void;
  onDeactivate?: () => void;
  disable?: boolean;
}) => {
  const [isActive, setActive] = useState<boolean>(controlledActive ?? false)
  const activeRef = useRef<boolean>(isActive)

  // sync state as controlled component
  useLayoutEffect(() => {
    if (controlledActive !== undefined) {
      activeRef.current = controlledActive
      setActive(activeRef.current)
    }
  }, [controlledActive])
  // ...
}

Why this design?

双模式设计让组件既可以直接使用(非受控,通过快捷键触发),也可以被外部状态控制(受控,适合自定义 UI 集成)。activeRef 的存在是为了在事件回调中同步读取最新状态,避免闭包陷阱。

What if alternative?

如果只支持受控模式,用户需要自行管理状态;如果只支持非受控模式,则无法与外部 UI 联动。双模式虽然增加了复杂度,但提供了最大的灵活性。

2.2 快捷键系统与事件拦截

// packages/inspector/src/Inspector/hooks/use-hotkey-toggle.ts
export const useHotkeyToggle = ({
  keys,
  disable,
  activate,
  deactivate,
  activeRef,
}: {
  keys?: string[] | null;
  disable?: boolean;
  activate: () => void;
  deactivate: () => void;
  activeRef: MutableRefObject<boolean>;
}) => {
  const hotkey: string | null = keys === null
    ? null
    : (keys ?? []).join('+')

  useEffect(() => {
    const handleHotKeys = (event?: KeyboardEvent) => {
      event?.preventDefault()
      event?.stopImmediatePropagation()
      activeRef.current ? deactivate() : activate()
    }

    const bindKey = (hotkey === null || disable)
      ? null
      : (hotkey || defaultHotkeys().join('+'))

    if (bindKey) {
      hotkeys(bindKey, { capture: true, element: window as any }, handleHotKeys)
      return () => { hotkeys.unbind(bindKey, handleHotKeys) }
    }
  }, [hotkey, disable])
}

默认快捷键在 macOS 上是 Ctrl+Shift+Command+C,其他平台是 Ctrl+Shift+Alt+C。使用 capture: true 确保事件在捕获阶段被拦截,避免被页面其他逻辑阻止。


第三章:Agent 架构——可扩展的检测代理层

3.1 InspectAgent 接口设计

react-dev-inspector v2.1.0 引入了 InspectAgent 架构,将检测逻辑从 React DOM 抽象出来,支持 React Native、React Three.js 等不同渲染器。

// packages/inspector/src/Inspector/types.ts
export interface InspectAgent<Element> {
  activate: (params: {
    onHover: (params: { element: Element; pointer: PointerEvent }) => void;
    onPointerDown: (params: { element?: Element; pointer: PointerEvent }) => void;
    onClick: (params: { element?: Element; pointer: PointerEvent }) => void;
  }) => void;

  deactivate: () => void;

  getTopElementFromPointer?: (pointer: Pointer) => MaybePromise<Element | undefined | null>;
  getTopElementsFromPointer?: (pointer: Pointer) => MaybePromise<Element[]>;

  isAgentElement: (element: unknown) => element is Element;

  getRenderChain(element: Element): InspectChainGenerator<Element>;
  getSourceChain(element: Element): InspectChainGenerator<Element>;

  getNameInfo: (element: Element) => { name: string; title: string } | undefined;
  findCodeInfo: (element: Element) => CodeInfo | undefined;
  findElementFiber?: (element: Element) => Fiber | undefined;

  indicate: (params: { element: Element; codeInfo?: CodeInfo; pointer?: PointerEvent; name?: string; title?: string }) => void;
  removeIndicate: () => void;
}

Why this design?

Agent 架构的核心思想是"分离关注点":

  • Inspector 组件负责状态管理和生命周期
  • InspectAgent 负责特定渲染器的元素检测和交互
  • 通过泛型 Element 支持不同类型的渲染目标(DOM 元素、3D 对象等)

What if alternative?

如果不使用 Agent 架构,所有检测逻辑会耦合在 Inspector 组件中,难以扩展。Agent 架构让社区可以为不同渲染器贡献检测能力,而无需修改核心代码。

3.2 DOMInspectAgent 的实现

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
export class DOMInspectAgent implements InspectAgent<DOMElement> {
  #overlay?: Overlay
  #unsubscribeListener?: () => void

  public activate = ({ onHover, onPointerDown, onClick }) => {
    this.deactivate()
    this.#overlay = new Overlay()

    this.#unsubscribeListener = setupPointerListener({
      onPointerOver: onHover,
      onPointerDown,
      onClick,
      preventEvents: this.#preventEvents,
    })
  }

  public getTopElementFromPointer = (pointer: Pointer): DOMElement | undefined | null => {
    return document.elementFromPoint(pointer.clientX, pointer.clientY) as DOMElement | undefined
  }

  public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
    let fiber: Fiber | undefined | null

    while (element) {
      fiber = getElementFiber(element)
      if (fiber) break

      yield {
        agent: this,
        element,
        title: element.nodeName.toLowerCase(),
        tags: getDOMElementTags(element),
      }
      element = element.parentElement as DOMElement
    }

    function *fiberChain(): Generator<Fiber, void, void> {
      while (fiber) {
        yield fiber
        if (fiber.return === fiber) return
        fiber = fiber.return
      }
    }

    return yield * genInspectChainFromFibers<DOMElement>({
      agent: this,
      fibers: fiberChain(),
      isAgentElement: this.isAgentElement,
      getElementTags: getDOMElementTags,
    })
  }
}

getRenderChain 是一个生成器函数,它从目标元素向上遍历:

  1. 首先遍历 DOM 树,直到找到带有 Fiber 的节点
  2. 然后遍历 Fiber 树(通过 fiber.return
  3. 每个节点生成一个 InspectChainItem,包含显示名称、标签、源码信息等

第四章:Fiber 遍历——React 内部结构的探索

4.1 从 DOM 元素获取 Fiber

React 在 DOM 元素上存储了对应的 Fiber 引用,键名随版本变化:

// packages/inspector/src/Inspector/utils/fiber.ts
export const getElementFiber = (_element?: Element): Fiber | undefined => {
  const element = _element as ElementWithFiber
  if (!element) return undefined

  // 优先通过 React DevTools Hook 获取
  const fiberByDevtoolHook = getFiberWithDevtoolHook(element)
  if (fiberByDevtoolHook) return fiberByDevtoolHook

  // 缓存已知的 fiber key,避免重复遍历
  for (const cachedFiberKey of cachedFiberKeys) {
    if (element[cachedFiberKey]) return element[cachedFiberKey] as Fiber
  }

  // 查找 fiber key(React >= v16.14.0 使用 __reactFiber$)
  const fiberKey = Object.keys(element).find(key => (
    key.startsWith('__reactFiber$') ||
    key.startsWith('__reactInternalInstance$')
  ))

  if (fiberKey) {
    cachedFiberKeys.add(fiberKey)
    return element[fiberKey] as Fiber
  }
  return undefined
}

Why this design?

直接访问 React 内部属性看似"hacky",但这是官方 DevTools 也在使用的方式。缓存机制避免了重复遍历对象键,提升了性能。

4.2 获取 Reference Fiber(智能组件识别)

// packages/inspector/src/Inspector/utils/inspect.ts
export const getReferenceFiber = (baseFiber?: Fiber): Fiber | undefined => {
  if (!baseFiber) return undefined

  const directParent = getDirectParentFiber(baseFiber)
  if (!directParent) return undefined

  const isParentNative = isNativeTagFiber(directParent)
  const isOnlyOneChild = !directParent.child!.sibling

  let referenceFiber = (!isParentNative && isOnlyOneChild)
    ? directParent
    : baseFiber

  const originReferenceFiber = referenceFiber

  // 向上查找直到找到有源码信息的 Fiber
  while (referenceFiber) {
    if (getCodeInfoFromFiber(referenceFiber))
      return referenceFiber
    referenceFiber = referenceFiber.return!
  }

  return originReferenceFiber
}

这个函数解决了一个关键问题:用户点击的是 DOM 元素(如 <div>),但想跳转到对应的 React 组件(如 <Button>)。判断逻辑是:

  • 如果父节点是原生标签(如 div),则返回当前 Fiber
  • 如果父节点是组件且只有一个子节点,则返回父组件(因为当前元素可能是组件的"外壳")

What if alternative?

如果不做这种智能识别,用户点击 <Button> 组件渲染的 <button> 元素时,可能会跳转到 button 标签的位置,而不是 Button 组件的定义处。

4.3 Render Chain vs Source Chain

// packages/inspector/src/Inspector/DOMInspectAgent/DOMInspectAgent.ts
public *getRenderChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  // 通过 fiber.return 遍历渲染树
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber) return
      fiber = fiber.return
    }
  }
  // ...
}

public *getSourceChain(element: DOMElement): Generator<InspectChainItem<DOMElement>, unknown, void> {
  function *fiberChain(): Generator<Fiber, void, void> {
    while (fiber) {
      yield fiber
      if (fiber.return === fiber || fiber._debugOwner === fiber) return
      fiber = fiber._debugOwner ?? fiber.return  // 优先使用 _debugOwner
    }
  }
  // ...
}
  • Render Chain:按照组件渲染层次遍历(父组件 → 子组件)
  • Source Chain:按照源码定义层次遍历(_debugOwner 指向 JSX 中定义该组件的父组件)

两者的区别在处理 HOC、ForwardRef、Context 等场景时尤为重要。


第五章:服务端链路——从 HTTP 请求到 IDE 进程

5.1 客户端发起请求

// packages/inspector/src/Inspector/utils/editor.ts
export const gotoServerEditor = (_codeInfo?: CodeInfoLike, options?: { editor?: TrustedEditor }) => {
  if (!_codeInfo) return
  const codeInfo = getCodeInfo(_codeInfo)

  const { lineNumber, columnNumber, relativePath, absolutePath } = codeInfo
  const isRelative = Boolean(relativePath)
  const fileName = isRelative ? relativePath : absolutePath

  const launchParams: LaunchEditorParams = {
    fileName,
    lineNumber,
    colNumber: columnNumber,
    editor: options?.editor,
  }

  const urlParams = new URLSearchParams(
    Object.entries(launchParams).filter(([, value]) => Boolean(value)) as [string, string][]
  )

  fetchToServerEditor({
    apiUrl: launchEditorEndpoint,  // '/__inspect-open-in-editor'
    urlParams,
    fallbackUrl: reactDevUtilsLaunchEditorEndpoint,  // 兼容旧版本
  })
}

5.2 服务端 Middleware 处理

// packages/middleware/src/launch-editor.ts
export const launchEditorMiddleware: NextHandleFunction = (req: IncomingRequest, res, next) => {
  if (!req.url?.startsWith(launchEditorEndpoint)) {
    return next()
  }

  const url = new URL(req.url, 'https://placeholder.domain')
  const params = Object.fromEntries(url.searchParams.entries()) as unknown as LaunchEditorParams

  if (!params.fileName) {
    res.statusCode = 400
    return res.end(`[launch-editor-middleware]: required query param "fileName" is missing.`)
  }

  const fileName = path.resolve(process.cwd(), params.fileName)

  let filePathWithLines = fileName
  if (params.lineNumber) {
    filePathWithLines += `:${params.lineNumber}`
    if (params.colNumber) {
      filePathWithLines += `:${params.colNumber}`
    }
  }

  // 编辑器优先级:请求参数 > LAUNCH_EDITOR 环境变量 > REACT_EDITOR 环境变量 > 默认 VSCode
  const editor = params.editor
    ? params.editor
    : (process.env.LAUNCH_EDITOR || process.env.REACT_EDITOR || TrustedEditor.VSCode)

  launchEditor(filePathWithLines, editor)
  res.end()
}

Why this design?

使用 HTTP 请求作为客户端与服务端的通信方式有以下优势:

  1. 简单通用,不依赖特定的构建工具
  2. 可以跨域(如果 IDE 和浏览器在不同环境)
  3. 易于调试和监控

What if alternative?

也可以使用 WebSocket 或 Electron IPC(如果是 Electron 应用),但 HTTP 是最通用、最易于集成的方式。

5.3 完整的调用链路

sequenceDiagram
    participant User as 用户
    participant Browser as 浏览器
    participant Inspector as Inspector组件
    participant Agent as DOMInspectAgent
    participant FiberUtils as Fiber工具函数
    participant Middleware as Express Middleware
    participant IDE as VSCode/IDE

    User->>Browser: 按下快捷键 Cmd+Shift+Ctrl+C
    Browser->>Inspector: 触发 activate
    Inspector->>Agent: activate({ onHover, onClick })
    Agent->>Browser: 注册 pointerover/click 事件监听

    User->>Browser: 鼠标悬停/点击元素
    Browser->>Agent: 触发 onHover/onClick
    Agent->>FiberUtils: getElementFiber(element)
    FiberUtils-->>Agent: 返回 Fiber
    Agent->>FiberUtils: getReferenceFiber(fiber)
    FiberUtils-->>Agent: 返回 referenceFiber
    Agent->>FiberUtils: getCodeInfoFromFiber(fiber)
    FiberUtils-->>Agent: 返回 CodeInfo

    Agent->>Inspector: 回调 onInspectElement
    Inspector->>Browser: fetch('/__inspect-open-in-editor?fileName=...')
    Browser->>Middleware: HTTP GET 请求
    Middleware->>Middleware: 解析 fileName, lineNumber, colNumber
    Middleware->>IDE: launchEditor(filePath, editor)
    IDE-->>User: 打开文件并定位到指定行列

第六章:Web Components——跨框架的 UI 层

6.1 Overlay 高亮组件

// packages/web-components/src/Overlay/Overlay.ts
export class Overlay {
  window: Window
  overlay: InspectorOverlayElement

  constructor() {
    customElement(InspectorOverlayTagName, InspectorOverlay)

    const currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window
    this.window = currentWindow

    const doc = currentWindow.document
    this.overlay = document.createElement(InspectorOverlayTagName)
    doc.body.appendChild(this.overlay)
  }

  public inspect<Element = HTMLElement>({
    element,
    title,
    info,
  }: {
    element?: Element;
    title?: string;
    info?: string;
  }) {
    return this.overlay.inspect({ element, title, info })
  }
}

使用 Web Components(基于 Solid.js 的 solid-element)实现 UI 层,有以下好处:

  1. 框架无关,可以在任何前端框架中使用
  2. 样式隔离,避免与宿主应用冲突
  3. 原生 API,无需额外的运行时依赖

6.2 InspectContextPanel 右键菜单

右键点击时显示的层级面板,让用户可以选择具体的组件层级:

// packages/web-components/src/InspectContextPanel/InspectContextPanel.ts
export class InspectContextPanel<Item extends ItemInfo = ItemInfo> {
  #panel: InspectContextPanelElement<Item> | undefined
  #clickOutsideCallbacks = new Set<() => void>()

  public show(params: InspectContextPanelShowParams<Item> & { onClickOutside?: () => void }) {
    this.#panel?.show(params)
    if (!params.onClickOutside) return
    this.#clickOutsideCallbacks.add(params.onClickOutside)
    this.listenClickOutside()
  }

  private listenClickOutside = () => {
    this.#clickOutsideSubscription = fromEvent<MouseEvent>(window, 'pointerdown', { capture: true })
      .pipe(
        filter(this.checkPointerOutside),
        tap(stopAndPreventEvent),
        switchMap(() => merge(
          fromEvent(window, 'pointerup', { capture: true }),
          fromEvent(window, 'click', { capture: true }).pipe(
            tap(() => {
              this.#clickOutsideCallbacks.forEach(callback => callback())
            }),
          ),
        )),
      ).subscribe()
  }
}

第七章:设计模式总结

7.1 分层架构

┌─────────────────────────────────────────────────────────────┐
│                      Presentation Layer                      │
│  ┌─────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
│  │   Overlay   │  │ InspectContext  │  │   Indicator     │  │
│  │  (Web Comp) │  │    Panel        │  │    (Web Comp)   │  │
│  └─────────────┘  └─────────────────┘  └─────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                       Agent Layer                            │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────┐  │
│  │  DOMInspectAgent│  │  RNInspectAgent │  │  Custom...  │  │
│  │   (React DOM)   │  │ (React Native)  │  │             │  │
│  └─────────────────┘  └─────────────────┘  └─────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                      Core Logic Layer                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   Fiber     │  │   Inspect   │  │   Chain Generator   │  │
│  │   Utils     │  │    Utils    │  │                     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
├─────────────────────────────────────────────────────────────┤
│                     Server Layer                             │
│  ┌─────────────────┐  ┌─────────────────────────────────┐   │
│  │   Middleware    │  │      launch-editor (npm)        │   │
│  │ (Express/Vite)  │  │                                 │   │
│  └─────────────────┘  └─────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

7.2 关键设计决策

决策点 选择 原因
坐标信息来源 Babel Plugin + _debugSource 双保险,优先使用 Plugin 的相对路径
Agent 架构 接口抽象 + 泛型 支持多渲染器,保持核心代码简洁
UI 实现 Web Components 框架无关,样式隔离
服务端通信 HTTP Middleware 通用、易集成、可调试
Fiber 获取 内部属性 + DevTools Hook 可靠且被官方认可的方式

总结:可借鉴的架构模式

  1. 编译时 + 运行时双管齐下:在编译时埋入元数据,在运行时读取并处理,是很多开发工具的核心模式

  2. Agent 架构解耦渲染器:通过接口抽象,让核心逻辑与具体渲染技术解耦

  3. 生成器函数处理层级遍历getRenderChaingetSourceChain 使用 Generator,既惰性又清晰

  4. 双模式组件设计:受控/非受控双模式让组件既易用又灵活

  5. Web Components 作为 UI 层:在 React 生态中使用 Web Components,实现真正的框架无关


参考链接

亿元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 是怎样把业务能力真正组织起来的。

❌
❌