普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月1日首页

你学不会 CSS,不是笨,是方向错了

2026年3月1日 22:17

做前端这么多年,最让我心疼的,就是那些拼尽全力学CSS,却越学越懵的新手。

后台私信里,几乎每天都能看到类似的倾诉:“学CSS大半年了,上百个属性背得滚瓜烂熟,flex、grid的教程刷了一遍又一遍,可一上手做项目,瞬间破防——布局乱得不成样子,兼容问题百出,改一个按钮样式,整个页面都崩了,到最后真的忍不住怀疑,我是不是真的不适合做前端?”

我太懂这种无力感了——当年我刚学CSS的时候,也踩过一模一样的坑,甚至有过深夜对着乱掉的页面,差点砸键盘放弃的瞬间。但今天,我一定要郑重地告诉你:你学不会CSS,真的不是笨,更不是不努力,而是从一开始,你就走错了学习的方向。

很多人学CSS,都陷入了一个致命的误区,也是最容易被忽略的陷阱——把CSS当成了“背属性、拼效果”的工具。今天刷到一个居中技巧,赶紧记在备忘录里;明天看到别人写的炫酷动画,复制粘贴过来凑数;现在更省事,直接丢给AI写,看似省了时间,实则学了个寂寞。

你以为自己学了很多东西,可那些碎片化的知识点,就像一堆散落的砖头,没有框架,没有逻辑,哪怕堆得再高,一阵风就能吹倒。CSS从来不是“堆砌属性”,就像盖房子,你光有砖头水泥不够,得先搭框架、打地基,才能盖出牢固的房子;学CSS也一样,你记再多属性,不懂底层逻辑、没有布局思维,写出来的代码永远是“散的”——出了bug找不到根源,改需求要全盘返工,越写越崩溃,越学越迷茫。

说句掏心窝子的话,我当年也傻过,天天死记硬背属性值,别人写的炫酷效果,我也跟着抄得不亦乐乎,可一到自己独立做项目,还是手忙脚乱,写出来的页面惨不忍睹。直到后来跟着公司的资深前端前辈学习,才突然开窍:CSS的核心,从来不是“记住多少属性”,而是“建立正确的思维”——布局思维、渲染思维、工程化思维。

举个最真实的例子,同样是写一个简单的商品卡片布局,新手和懂思维的开发者,差距真的天差地别:

新手(包括很多依赖AI的人),会直接把图片、标题、价格、按钮,一股脑堆在一个div里,用margin硬调间距,写一堆冗余又杂乱的代码,看似实现了效果,可一旦换个屏幕尺寸,图片和文字直接重叠,按钮跑到页面外面去,改都改不过来;而懂思维的开发者,会先静下心来拆结构、定布局,用最简洁的代码搭建骨架,后期不管是改间距、加功能,还是适配不同屏幕,只需要微调,根本不用全盘返工。

这就是方向的差距:你在死记硬背“怎么写”,纠结于一个属性的用法,而高手在思考“为什么这么写”“怎么写更稳妥”“怎么写能避免后期踩坑”——这也是为什么,同样是学CSS,有人越学越轻松,有人却越学越痛苦。

很多人都说CSS是“玄学”,其实根本不是!它有自己清晰的底层逻辑——层叠、优先级、BFC、渲染机制,只要你吃透这些,你会发现,所有的CSS问题都有章可循,根本不用死记硬背,也不用靠AI抄作业。

说到AI,我必须多提醒一句:AI可以当工具,但绝对不能当老师。它能给你现成的代码,却给不了你“避坑思维”,给不了你“可维护的逻辑”,你抄来的代码,看似省了一时的功夫,后期只会让你踩更多坑、加更多班,到最后,不仅没学会CSS,反而养成了依赖的习惯,越用越废。

如果你现在也正处于“学CSS越学越懵”的状态,如果你也在靠死记硬背、靠AI应付项目,如果你也因为写不好CSS而怀疑自己,不妨停下来,换个方向——先搞懂CSS的底层原理,再建立属于自己的布局思维,最后结合实战案例,把那些碎片化的知识点,串联成一套完整的体系。不用贪多求快,每天吃透一个核心逻辑,练一个实战案例,慢慢你就会发现,原来写CSS,真的可以很轻松,再也不用为了布局错乱、兼容问题而熬夜加班。

我把自己多年实战总结的CSS体系思维、高频避坑技巧、真实项目案例,全都整理成了掘金小册,没有花里胡哨的废话,全是能直接套进项目里的干货,从基础原理到工程化实战,一步步带你找对学习方向,摆脱死记硬背和AI依赖,真正学会写CSS。

除了掘金小册,我今年开始在专耕《CSS 工作坊》专栏,与大家一起探讨 CSS 方面的特性与实战!

不用怕自己基础差,不用怕学不会,只要找对方向,你也能轻松搞定CSS,告别改样式加班的痛苦,摆脱自我怀疑,真正感受到写CSS的乐趣。

最后,想问问正在学CSS的你:你有没有踩过“死记属性”“依赖AI”的坑?有没有因为写不好CSS而崩溃过?评论区聊聊,我帮你避坑,陪你一起把CSS学扎实~

觉得有用的话,点个赞+收藏,跟着我,少走半年弯路,彻底搞定CSS,不再被样式折磨!

用 HTMX 为 React Data Grid 加速实时更新

2026年3月1日 22:04

原文:Integrating HTMX into a React Data Grid for Real‑Time Updates in Next.js

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

React 非常适合构建动态交互界面,但随着应用增长,客户端渲染开销、包体积和状态管理复杂度会逐渐增加。

HTMX 提供了另一条路径:通过 HTML 属性驱动请求与局部替换,把一部分更新逻辑交还给服务端。

本文将演示如何在 Next.js(React 19)中集成 HTMX,并结合 Syncfusion React Data Grid,通过单个 SSE 连接实现实时更新。

为什么 HTMX 适合 React + Next.js

HTMX 并不是为了替代 React,而是作为一个轻量增强层:

  • 通过 hx-gethx-swaphx-trigger 等属性,浏览器可以在指定事件触发时自动发起请求,并把响应片段直接更新到 DOM。
  • 在 Data Grid 这种“更新频繁、改动局部”的场景中,让服务端返回 HTML 片段,通常比在客户端维护大量同步状态更直接。

典型例子是仪表盘或 CRUD 页面:某些单元格需要高频刷新。如果完全由客户端状态管理驱动,复杂度和性能压力会快速上升;而 HTMX 正擅长这种“局部、频繁、小改动”的更新模式。

在 Next.js(React 19)项目中接入 HTMX

前置条件

  • Node.js 20+
  • npm / pnpm / yarn
  • Next.js 15.1+
  • React / React-DOM 19
  • 任意编辑器(如 VS Code)

第 1 步:创建 Next.js 项目

npx create-next-app@latest my-htmx-app --typescript --app
cd my-htmx-app && npm install

第 2 步:在 Layout 中加载 HTMX

HTMX 尽量在页面生命周期更早的阶段加载,作者建议直接放进 app/layout.tsx,确保 hx-* 属性立即可用,同时启用 SSE 扩展。

示例(原文思路整理版):

import Script from "next/script";

export default function RootLayout({ children }: { children: React.ReactNode }) {
   return (
      <html lang="en">
         <head>
            <Script src="https://unpkg.com/htmx.org@2.0.1" strategy="beforeInteractive" />
            <Script
               src="https://unpkg.com/htmx.org@1.9.10/dist/ext/sse.js"
               strategy="beforeInteractive"
            />
         </head>
         <body>{children}</body>
      </html>
   );
}

作者给出的理由是:

  • 让 Next.js 处理脚本加载顺序
  • 不需要打包或额外的构建配置
  • HTMX 可同时作用于 SSR/CSR 渲染出的 DOM

第 3 步:安装并配置 Syncfusion React 组件

npm install @syncfusion/ej2-react-grids @syncfusion/ej2-react-buttons

在全局样式中引入样式(原文使用 Tailwind 主题样式):

@import "@syncfusion/ej2-react-grids/styles/tailwind.css";
@import "@syncfusion/ej2-react-buttons/styles/tailwind.css";

在 React Data Grid 中实现实时更新

作者举了一个简单的订单列表:列包括 OrderIDCustomerIDFreight,并让 Freight 每 5 秒更新一次,模拟实时价格变化。

常见误区:每行一个 SSE 连接

直觉上,你可能会让每一行自己开一条 SSE 连接来收更新。但浏览器对并发 SSE 连接数有上限,作者指出“前几行能工作,后面就不行了”。

解决方案:一个 SSE 端点广播所有行

核心思路:

  • 只建立 一个 SSE 连接
  • 服务端每次推送时,为每行发送一个具名事件,例如 freight-updated-1001
  • 每个单元格只监听属于自己的事件名

这样可以绕开连接数限制,同时依然做到“行级别、单元格级别”的更新。

创建 Data Grid(React + HTMX)

作者给出的示例代码(保留原意并整理为可读格式)。注意:原文示例里 Freight 字段名处存在一个引号/拼写小问题,这里按语义修正为 Freight

import { useEffect } from "react";
import { GridComponent, ColumnsDirective, ColumnDirective } from "@syncfusion/ej2-react-grids";

declare global {
   interface Window {
      htmx?: any;
   }
}

const data = Array.from({ length: 10 }, (_, i) => ({
   OrderID: 1000 + i + 1,
   CustomerID: ["ALFKI", "ANANTR", "ANTON", "BLONP", "BOLID"][Math.floor(Math.random() * 5)],
   OrderDate: new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString(),
   Freight: (2.1 * (i + 1)).toFixed(2),
}));

export default function Home() {
   useEffect(() => {
      if (typeof window === "undefined" || !window.htmx) {
         console.error("HTMX not loaded");
         return;
      }

      const container = document.querySelector("#htmx-container");
      if (container) {
         window.htmx.process(container);
      }

      const observer = new MutationObserver(() => {
         if (container) window.htmx.process(container);
      });

      observer.observe(container || document.body, { childList: true, subtree: true });
      return () => observer.disconnect();
   }, []);

   return (
      <div id="htmx-container" className="p-6 max-w-4xl mx-auto">
         <GridComponent dataSource={data} className="border rounded-lg shadow" allowPaging={false}>
            <ColumnsDirective>
               <ColumnDirective field="OrderID" headerText="Order ID" width="80" textAlign="Right" />
               <ColumnDirective field="CustomerID" headerText="Customer" width="100" />
               <ColumnDirective
                  field="Freight"
                  headerText="Freight"
                  width="80"
                  textAlign="Right"
                  template={(props: any) => (
                     <div
                        data-hx-sse={`connect:/api/updates swap:freight-updated-${props.OrderID}`}
                        data-hx-target="this"
                        data-hx-swap="innerHTML"
                        className="p-1"
                     >
                        {props.Freight}
                     </div>
                  )}
               />
            </ColumnsDirective>
         </GridComponent>
      </div>
   );
}

关键点是 data-hx-sse:它负责连接 SSE 并监听事件,然后把事件数据替换到当前单元格里。

创建 SSE 端点

服务端用一个静态 API 路由持续输出 text/event-stream

import { NextResponse } from "next/server";

export async function GET(request: Request) {
   const headers = {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
   };

   const stream = new ReadableStream({
      async start(controller) {
         const interval = setInterval(() => {
            for (let i = 1001; i <= 1010; i++) {
               const newFreight = (Math.random() * 100).toFixed(2);
               const payload = `event: freight-updated-${i}\ndata: ${newFreight}\n\n`;
               controller.enqueue(new TextEncoder().encode(payload));
            }
         }, 5000);

         request.signal.addEventListener("abort", () => {
            clearInterval(interval);
            controller.close();
         });
      },
   });

   return new NextResponse(stream, { headers });
}

这样,单个 SSE 连接就能把 10 行(甚至更多)的 Freight 更新“广播”出去,而每个单元格只消费自己关心的事件。

最终效果(原文动图):

GitHub 参考

示例代码仓库:

常见问题(FAQ)

为什么要把 HTMX 和 React 混用?

作者的回答是:HTMX 负责“快、轻、局部”的 HTML 替换(表单、懒加载区块、局部刷新、实时更新等),React 负责复杂、状态密集的 UI 部分。组合起来的结果是:

  • 更小的包体积
  • 更快的主观速度
  • 更少的前端状态与胶水代码

什么时候选择 React + HTMX,而不是全靠 React/Next.js?

适合这些情况:

  • 你想把 JS 负载压到更小
  • 交互大多可以通过服务端驱动的局部更新完成
  • 后端本来就能产出不错的 HTML
  • “速度 + 简洁”比“复杂的客户端状态”更重要

结语

把 HTMX 和 Next.js / React Data Grid 组合在一起,你可以同时得到:React 的组件化能力 + HTMX 的轻量局部更新能力。在需要实时更新、但又不想引入额外复杂状态层的 Data Grid 场景里,这是一条非常值得尝试的路线。

让 JavaScript 更容易「善后」的新能力

2026年3月1日 22:02

原文:It’s about to get a lot easier for your JavaScript to clean up after itself

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

wechat_2026-03-01_220216_455.png

JavaScript 开发者大致可以分成两类:一类偏“随性”,一类偏“整理控”。作者说自己在现实生活里并不整洁,但写 JavaScript 时会非常在意秩序:默认使用 const、重视作用域,并希望代码在完成工作后把资源也清理干净。

也正因为如此,他对 TC39 的 Explicit Resource Management(显式资源管理)提案非常兴奋:这个提案不仅把许多已有实践系统化,还希望给 JavaScript 提供统一、可靠的资源清理机制。

本文会先介绍“隐式资源管理”,再进入“显式资源管理”的核心能力:[Symbol.dispose] 与新的 using 声明。

隐式资源管理(Implicit resource management)

如果你用过 WeakSetWeakMap,其实已经见过一种“隐式资源管理”的思想。

WeakSet / WeakMap 的 “weak(弱引用)”含义是:它们对值(或 key)的引用不会阻止垃圾回收(GC)。当某个对象在程序里不再被其他地方引用时,它就有机会被回收;一旦被回收,WeakSet/WeakMap 里对应的条目也可能随之消失。

因此,WeakSet/WeakMap 只能存放可被 GC 的值:对象引用,以及未注册到全局 Symbol 注册表的 Symbol。比如尝试把 true 这种原始值放进 WeakSet,会报错:

const theWeakSet = new WeakSet([true]);

WeakMap 的典型用途是:给某个对象“外挂”一些关联数据,但又不把数据真的挂在对象本身上,同时也不阻止对象被 GC:

const theObject = {};
const theWeakMap = new WeakMap([[theObject, "A string, say, describing the object."]]);

console.log(theWeakMap.get(theObject));

看上去很美:对象没了,关联数据也应该跟着消失——像极了“代码会自己打扫卫生”。

不过作者也提醒:垃圾回收何时发生是不确定的。也就是说,即便对象已经没有其他引用,你也不能保证它立刻被回收;因此 WeakMap 里的条目也不一定马上消失。

隐式资源管理的好处是“你不用管”;坏处是“你也管不了”。

显式资源管理(Explicit resource management)

显式资源管理并不是让你手动管理内存(GC 依然是引擎的事),它解决的是另一类更常见、更工程化的问题:

当某个资源“用完了”,我们希望能确定执行一组清理动作。

这里的“资源”可以理解为:有明确“结束状态”的对象。例如:文件句柄、WebSocket 连接、流、锁、订阅、观察者、以及各种需要 close() / disconnect() / abort() 的东西。

作者用 generator 举例,说明“生命周期结束时执行清理”在 JS 里并不陌生:generator 的 done 会在迭代结束时变成 true;并且你可以在 generator 内用 try...finally 来保证收尾逻辑被执行。

一个简化示例:

function* generatorFunction() {
try {
yield true;
yield false;
} finally {
console.log("All done.");
}
}

const generatorObject = generatorFunction();

console.log(generatorObject.next());
console.log(generatorObject.next());
console.log(generatorObject.next());

如果你提前调用 return(),也会走到 finally

console.log(generatorObject.return());

作者把这种“我明确地让它现在结束并清理”的方式称为命令式(imperative)资源管理:比如你手动调用 close()abort()disconnect()

问题在于:这些清理方法在不同 API 里名字五花八门,而我们做的事却高度一致——“把它关掉、清理掉”。于是提案引入了一个统一约定:

  • 对需要清理的资源,提供一个标准方法:[Symbol.dispose]()

以 generator 为例,它可以把 [Symbol.dispose] 标准化为对 return() 的包装:

console.log(generatorObject[Symbol.dispose]());

这在 generator 场景里看起来变化不大,但意义很大:它为“任何需要清理的资源”提供了统一入口。

using:声明式资源管理

有了统一的 [Symbol.dispose](),提案就可以再向前一步:提供声明式(declarative)资源管理

也就是:不再靠“记得手动调用 dispose”,而是把资源的清理动作绑定到作用域生命周期上。

提案为此引入了一个新的变量声明关键字:using

  • using 声明是块级作用域(和 const / let 类似)。
  • using 声明的绑定不可重新赋值(像 const)。
  • 当代码执行离开该作用域时,引擎会自动调用资源的 disposer,即 resource[Symbol.dispose]()

一个最小示例:

{
using theObject = {
[Symbol.dispose]() {
console.log("All done.");
},
};
// 离开作用域时,会自动输出 "All done."
}

需要注意:using 不是“更酷的 const”。它只能用于:

  • null / undefined
  • 或者拥有 [Symbol.dispose]() 的对象

比如这样会报错(因为 {} 没有 disposer):

{
using theObject = {};
}

并且 using 必须处在某个明确的作用域中(块、函数体、静态初始化块、for/for-of/for-await-of 的初始化部分,或模块顶层),否则它就没有“离开作用域”这一刻,也就失去了意义。

回到文章前面那个“把文件开着就走了”的 generator 场景:如果用 using 来声明 generator 对象,那么在离开作用域时就会自动触发清理:

{
function* generatorFunction() {
console.log("Open a file.");
try {
yield true;
yield false;
} finally {
console.log("Close the file.");
}
}

using generatorObject = generatorFunction();
console.log(generatorObject.next());
}

同理,如果你写一个类实例需要“用完自动收尾”,也可以直接实现 [Symbol.dispose]()

class TheClass {
theFile;

constructor(theFile) {
this.theFile = theFile;
console.log(`Open ${theFile}`);
}

[Symbol.dispose]() {
console.log(`Close ${this.theFile}`);
}
}

const theFile = "./some-file";

if (theFile) {
using fileOpener = new TheClass(theFile);
console.log(`Do things with ${fileOpener.constructor.name}, then...`);
}

现状与落地

作者提到:该提案已进入 TC39 Stage 3(推荐实现),并且大多数浏览器已经支持(Safari 仍缺席)。你可以在 caniuse 上查看:

当然,Stage 3 仍然意味着“可能还有语法细节会变”,所以更适合现在就开始在实验/非生产环境熟悉它。

作者最后把这件事总结为一种很朴素、但非常工程化的收益:

JS 终于开始从“全靠自觉的清理”走向“语言级别帮助你不忘记清理”。

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

作者 SmalBox
2026年3月1日 21:09

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

Dielectric Specular 节点是 Unity URP Shader Graph 中用于物理渲染的重要工具,专门用于计算介电材质(非金属材质)的基础反射率 F0 值。在基于物理的渲染(PBR)工作流中,准确表示材质的光学特性至关重要,该节点通过预定义的物理参数简化了这一过程,让开发者能够快速实现真实感渲染效果。

描述

Dielectric Specular 节点返回物理材质的介电镜面反射 (Dielectric Specular) F0 值,这是 PBR 渲染中描述非金属材质表面基础反射率的关键参数。F0 代表材质在垂直入射角度(即法线方向)的反射率,对于介电材质而言,这个值通常较低且相对恒定。

通过节点上的 Material 下拉选单参数,用户可以选择不同类型的预设材质,每种材质都有其特定的 F0 值范围,这些值基于真实世界的物理测量数据。

Common 材质类型定义了 0.034 到 0.048 的 sRGB 值范围,这个范围覆盖了大多数常见介电材质的基础反射率。使用 Range 参数可以在这个范围内进行线性插值,选择精确的 F0 值。这种材质类型特别适用于塑料、织物、木材、橡胶等广泛的非金属材质,为这些材质提供了物理准确的反射起点。

对于需要更精确控制的特殊情况,可以使用 Custom材料类型来自定义物理材质值。在这种模式下,输出值由材质的折射率(Index of Refraction,简称 IOR)直接计算得出。折射率可以通过 IOR 参数进行设置,节点会自动使用菲涅耳方程计算对应的 F0 值。

该节点的设计遵循了物理光学原理,确保了渲染结果的真实性和一致性。在复杂的照明环境中,正确的 F0 值能够确保材质在不同角度和光照条件下表现出正确的反射行为,这是实现高质量 PBR 渲染的基础。

端口

Dielectric Specular 节点的端口配置简洁明了,只包含一个输出端口:

名称 方向 类型 绑定 描述
Out 输出 Float 输出计算得到的介电镜面反射 F0 值

这个单输出设计反映了节点的专用性 - 它专注于提供准确的 F0 值,而不涉及其他材质属性的计算。输出值是一个浮点数,表示在 sRGB 颜色空间中的反射率值,可以直接连接到 Shader Graph 中的其他节点,特别是与反射、高光相关的输入。

在实际使用中,这个输出值通常会被连接到:

  • 高光反射计算节点
  • 环境反射节点
  • PBR 主节点的 Specular 输入
  • 自定义光照模型中的反射率参数

控件

Dielectric Specular 节点提供了直观的控件系统,让用户能够灵活地调整材质的光学属性:

名称 类型 选项 描述
Material 下拉选单 Common、RustedMetal、Water、Ice、Glass、Custom 选择要输出的材质类型,每种类型对应不同的 F0 值或计算方式
Range 滑动条 0.0 到 1.0 控制 Common 材质类型的输出值,在 0.034 到 0.048 范围内进行线性插值
IOR 滑动条 1.0 到 3.0 控制 Custom 材质类型的折射率,用于计算自定义的 F0 值

Material 下拉选单详解

Material 下拉选单是节点的核心控制,提供了六种不同的材质选项:

  • Common:通用介电材质,适用于大多数塑料、橡胶、织物等常见非金属材质。F0 值范围从 0.034(约 4%)到 0.048(约 5%),这个范围基于对常见塑料材质的实际测量数据。
  • RustedMetal:锈蚀金属材质,F0 值为 0.030(3%)。虽然金属本身是导体而非介电体,但锈蚀层表现为介电特性,这个预设适用于表现金属表面的氧化层或涂层。
  • Water:水材质,F0 值为 0.020(2%)。基于水的折射率(约 1.33)计算得出,适用于水体、液体表面的渲染。
  • Ice:冰材质,F0 值为 0.018(1.8%)。基于冰的折射率(约 1.31)计算,适用于冰块、冰面等冷冻水体的表现。
  • Glass:玻璃材质,F0 值为 0.040(4%)。基于典型玻璃的折射率(约 1.5)计算,适用于各种玻璃制品的渲染。
  • Custom:自定义材质,允许用户通过设置折射率(IOR)来自定义 F0 值。这种模式适用于特殊材质或需要精确控制的光学效果。

Range 滑动条

Range 滑动条仅在选择了 Common 材质类型时可用,它控制着在 0.034 到 0.048 范围内的线性插值:

  • 当值为 0.0 时,输出 0.034
  • 当值为 1.0 时,输出 0.048
  • 中间值按线性关系插值

这个设计允许用户在常见塑料材质的反射率范围内进行微调,以适应不同光泽度和成分的塑料材质。

IOR 滑动条

IOR 滑动条仅在选择了 Custom 材质类型时可用,它控制材质的折射率:

  • 折射率范围从 1.0(真空)到 3.0(高折射率材料)
  • 默认值为 1.0
  • 节点使用菲涅耳方程计算对应的 F0 值

折射率是描述光在材质中传播速度减慢程度的物理量,直接影响材质的反射特性。常见材质的折射率包括:

  • 空气:约 1.0
  • 水:约 1.33
  • 玻璃:约 1.5
  • 钻石:约 2.42

生成的代码示例

以下示例代码展示了 Dielectric Specular 节点在不同材质模式下生成的 HLSL 代码,这些代码揭示了节点内部的数学计算原理:

Common 材质模式

HLSL

float _DielectricSpecular_Range = 0.5;
float _DielectricSpecular_Out = lerp(0.034, 0.048, _DielectricSpecular_Range);

在 Common 模式下,节点使用线性插值(lerp)函数在 0.034 和 0.048 之间计算最终的 F0 值。Range 参数控制插值的权重,0.0 对应最小值,1.0 对应最大值,0.5 则对应中间值 0.041。

RustedMetal 材质模式

HLSL

float _DielectricSpecular_Out = 0.030;

RustedMetal 模式直接返回固定的 F0 值 0.030,这个值基于对锈蚀金属表面的光学测量数据。

Water 材质模式

HLSL

float _DielectricSpecular_Out = 0.020;

Water 模式返回水的标准 F0 值 0.020,这个值由水的折射率(约 1.33)通过菲涅耳方程计算得出。

Ice 材质模式

HLSL

float _DielectricSpecular_Out = 0.018;

Ice 模式返回冰的 F0 值 0.018,略低于水,反映了冰的稍低折射率(约 1.31)。

Glass 材质模式

HLSL

float _DielectricSpecular_Out = 0.040;

Glass 模式返回典型玻璃的 F0 值 0.040,基于玻璃的标准折射率 1.5 计算。

Custom 材质模式

HLSL

float _DielectricSpecular_IOR = 1;
float _DielectricSpecular_Out = pow(_Node_IOR - 1, 2) / pow(_DielectricSpecular_IOR + 1, 2);

Custom 模式使用菲涅耳方程计算 F0 值,公式为:F0 = ((IOR - 1)/(IOR + 1))²。这是光学中描述垂直入射反射率的标准公式,确保了物理准确性。

实际应用示例

塑料材质创建

创建一个逼真的塑料材质是 Dielectric Specular 节点的典型应用场景:

  1. 在 Shader Graph 中创建 Dielectric Specular 节点
  2. 将 Material 设置为 Common
  3. 调整 Range 参数到约 0.7 的位置,获得大约 0.044 的 F0 值
  4. 将输出连接到 PBR 主节点的 Specular 输入
  5. 设置合适的基础颜色、光滑度和法线贴图

这种设置能够创建出视觉上准确的塑料表面,在各类光照条件下都能保持一致的反射特性。

水体渲染

对于水体渲染,使用 Water 预设可以快速获得物理准确的水面反射:

  1. 选择 Water 材质类型
  2. 获得固定的 0.020 F0 值
  3. 结合法线贴图模拟水面波纹
  4. 使用透明度混合实现水体的视觉深度
  5. 添加折射效果增强真实感

自定义光学材质

当需要渲染特殊光学材质时,Custom 模式提供了完全的控制:

  1. 选择 Custom 材质类型
  2. 根据目标材质设置正确的 IOR 值
    • 普通玻璃:IOR = 1.5
    • 水晶:IOR = 1.55
    • 钻石:IOR = 2.42
  3. 节点自动计算对应的 F0 值
  4. 结合适当的光滑度和透明度设置

技术细节与最佳实践

颜色空间考虑

Dielectric Specular 节点输出的 F0 值是在 sRGB 颜色空间中定义的,这与 Unity 的默认颜色空间一致。在线性颜色空间项目中,这些值会自动进行正确的转换。

性能影响

Dielectric Specular 节点本身的计算开销极低,因为它只涉及简单的数值操作或预定义值的输出。在大多数情况下,使用该节点不会对渲染性能产生明显影响。

与其他节点的配合

Dielectric Specular 节点通常与其他 PBR 相关节点配合使用:

  • 与 Normal 节点结合定义表面微观结构
  • 与 Smoothness 节点结合控制高光大小和强度
  • 与环境反射节点结合实现准确的基于图像的照明

常见误区

  • 误用于金属材质:Dielectric Specular 节点专为介电材质设计,金属材质应使用不同的反射模型
  • 过度调整 Range:在 Common 模式下,保持 Range 在合理范围内(0.2-0.8)通常能获得更自然的结果
  • 忽略环境光照:F0 值的效果高度依赖环境光照,确保场景中有足够的环境反射信息

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

老司机 iOS 周报 #365 | 2026-03-02

作者 ChengzhiHuang
2026年3月1日 20:57

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

新手推荐

🐎 Swift Package Manager Mirrors for Local Development

@BluesJiang: 文章介绍了 Swift Pacakge Manager 的 Mirrors 的用法。当在开发时,可以在使用命令行,临时将 Package.swift 中声明的依赖从分发地址映射到本地仓库或者其他地址。不再需要手动的修改链接地址,并且还要在提交时刻意改回来或者不提交改动。是一个很好用的功能。

文章

🌟 🐕 Ioser 铭(iOS 开发 2015-2025)

@邦 Ben:看着 Ioser 著名 iOS 梗,以及「孙源(sunnyxx)」,「郭耀源(ibireme)」的「YYKit」,「唐巧」的《iOS 开发进阶》成为必读书目,「喵神王巍(onevcat)」的 OneV's Den,「Casa Taloyum(casatwy)」的组件化架构方案「Bang」的 JSPatch,「雷纯锋」分享的 MVVM 和 RAC 实践,「limboy」的技术思考,小虾等前辈在技术会议上分享等等,熟悉感拉满的名字,都看恍惚了。这十年里苹果生态的变化也大得很,从啃 UIKit、写 OC,到 Swift 慢慢成熟、SwiftUI 推出,再到空间计算一步步落地,新技术新框架换了一波又一波。但越干越明白,底层原理、扎实的工程能力从来没过时,不管技术怎么变,把问题拆透、把性能做优、把工程搭稳的核心思路一直没变。如今迈入 AI 时代,端侧智能、AI Agent 成为新方向,AI 也成了开发的好帮手,虽赛道不断拓展、工具持续升级,但本质的事情还是得持续迎向新事物学习,朋友们,一起加油,AI 时代有更好的机会。

🐕 How Apple Hooks Entire Frameworks

@Kyle-Ye: 文章深入分析了 Apple 如何通过 Method Swizzling 实现对整个框架的 hook,以 Main Thread Checker 为例,展示了其如何大规模替换数万个方法。作者介绍了基于 trampoline 的实现方案——为每个被 hook 的方法生成唯一的跳板函数,通过共享的汇编处理程序保存和恢复寄存器状态,再调用统一的回调。文章还探讨了如何通过运行时内存映射动态创建 trampoline 以突破数量限制,以及使用私有 API class_replaceMethodsBulk 批量替换方法以减少锁竞争从而提升性能。对于对 Objective-C Runtime 底层机制和性能优化感兴趣的开发者值得一读。

🐕 Should You Use All These Dependencies?

@Barney:从 iOS 项目依赖选择角度出发,作者以项目中仅用到 3 处 RxSwift 却引入 3MB 体积与编译成本为例,讨论“能不用库就不用”的默认立场。核心内容包括:

  • 决策标准:评估收益、迁移成本、团队熟悉度与长期维护负担,避免因熟悉而引入不必要抽象
  • 案例对比:以 Alamofire vs URLSession 说明第三方并非总是更省时,功能范围与实际需求应匹配
  • 风险控制:建议使用 Wrapper/Facade、版本锁定与定期依赖审计(工具 + 清单)降低锁定与弃坑风险

最后强调每个依赖都是“当下效率”与“未来债务”的权衡,适合建立团队的依赖准入与清理流程。

🐕 CKSyncEngine questions and answers

@AidenRao:苹果在 WWDC23 带来的 CKSyncEngine 毫无疑问是近年来最优秀的 API 之一,它将复杂的云同步逻辑大幅简化。但官方文档之外,仍有大量实践细节亟待探索。知名应用 Apollo 和 Pixel Pals 的作者 Christian Selig 近期分享了他在集成 CKSyncEngine 过程中的一系列实战问答。本文并非入门教程,而是围绕冲突解决、数据模型兼容、状态管理、错误处理等开发者必然会遇到的具体问题,提供了清晰的解决方案和代码示例。如果你正考虑为你的应用添加健壮的 CloudKit 同步能力,这份来自一线开发者的经验总结将极具价值。

工具

Happy:为 Codex/Claude Code 提供无缝的移动端交互

Happy (Happy Coder) 是一款开源的第三方配套应用,旨在为 Claude Code (以及 OpenAI Codex) 提供无缝的移动端交互体验。它并不是要取代你的桌面环境,而是通过“远程中继”方案,让你在离开工位时也能通过手机完全掌控 AI 的编程进度:

  • 无感切换 (Seamless Handoff):在电脑终端运行 happy 启动 Claude。当你合上电脑出门时,打开手机 App 即可实时接管刚才的对话和代码上下文,状态完全同步。
  • 权限即时推送:Claude Code 在执行高风险操作(如删除文件、运行复杂脚本)时需要授权。有了 Happy,你的手机会收到推送通知,点击即可远程“允许”或“拒绝”,无需死守在屏幕前。
  • 实时语音协作:集成了语音交互功能。你可以像跟真人交互一样直接发语音,在走路或通勤时向 Claude 描述需求,看着它在远程电脑上自动写代码。
  • 端到端加密 (E2E):安全性是其核心。它采用类似 Signal 的加密协议,代码和对话在传输前即在本地加密,开发者服务器无法读取你的任何代码内容。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

Hello 算法:众里寻她千“百度”

作者 灵感__idea
2026年3月1日 20:08

每个系列一本前端好书,帮你轻松学重点。

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《Hello,算法》

众里寻他千百度,深度遍历二叉树。

踏破铁鞋无觅处,广度优先无向图。

—— 《七言绝句》灵感~

话题向下展开之前,先聊个我们都玩过的游戏:猜数字

一方出数字,另一方猜。猜的时候,我们希望对方不只是给一个“对”或“错”的结果,而是能给出答案的相对大小,这样就可以通过逐步缩小范围来更快定位到最终答案。

这其实就是一个有关搜索的典型案例。

何为“搜索”

这是个不太需要解释的东西,百度搜索,业务数据搜索,不论是精准搜索,还是范围搜索,在日常需求中都十分常见。

搜索的过程是在数据结构中找到一个或一组满足特定条件的元素。

根据实现思路分为以下两类。

  • 遍历定位,如数组、链表、树和图的遍历等。
  • 利用数据结构或数据包含的先验信息,高效查找,如二分查找、哈希查找和二叉搜索树查找等。

猜数字采用的就是“二分查找”法,我们先来认识一下它。

二分查找

二分查找是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮缩小一半搜索范围,直至找到目标元素,或搜索区间为空。

通过一道题感受一下:

给定一个长度为 n 的数组 nums ,从小到大排列,且不重复,查找并返回元素 target 在该数组中的索引,若数组不包含该元素,则返回 -1。

6f97b261bdfc030c95deec4b34551bc2.jpg

解题思路:

1、拟定初始索引区间

2、计算中点索引

3、比较目标值与中点值的相对大小,决定下一步是向前算,还是向后算

4、重复以上3步

代码实现:

/* 二分查找(双闭区间) */
function binarySearch(nums, target) {
    // 初始化双闭区间 [0, n-1] ,即 i, j 分别指向数组首元素、尾元素
    let i = 0,
        j = nums.length - 1;
    // 循环,当搜索区间为空时跳出(当 i > j 时为空)
    while (i <= j) {
        // 计算中点索引 m ,使用 parseInt() 向下取整
        const m = parseInt(i + (j - i) / 2);
        if (nums[m] < target)
            // 此情况说明 target 在区间 [m+1, j] 中
            i = m + 1;
        else if (nums[m] > target)
            // 此情况说明 target 在区间 [i, m-1] 中
            j = m - 1;
        else return m; // 找到目标元素,返回其索引
    }
    // 未找到目标元素,返回 -1
    return -1;
}

优点与局限

二分查找在时间和空间方面都有较好的性能。

  • 时间效率高。当数据大小 n = 220 时,线性查找需要 220 轮循环,二分查找仅需 20 轮。
  • 相较于需要额外空间的搜索算法,更省空间。

然而,它并非适用所有情况,主要有以下原因。

  • 若数据无序,要先进行排序,得不偿失。
  • 二分查找需要跳跃式(非连续地)访问元素,而在链表中执行跳跃式访问的效率较低,因此不适合应用在链表或基于链表实现的数据结构。
  • 当数据量 n 较小时,线性查找需要做的操作和判断更少,反而比二分查找更快。

尽管二分查找存在不足,但更为不足的是人的思维,能用上二分查找已经可以被列入“聪明”范畴了。

通常,人们下意识的选择还是暴力搜索。

暴力搜索

暴力搜索指通过遍历来定位目标。同样是遍历,在“线性”和“非线性”的数据结构中又有所区别。

1、线性搜索

适用于数组和链表等线性结构。

它从数据结构的一端开始,逐个访问元素,直到找到目标元素,或到达另一端仍没有找到目标元素为止。

2、优先搜索

适用于图和树等非线性结构,又分为“广度优先”和“深度优先”。

广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。

深度优先搜索从初始节点开始,沿着一条路径走到头,再回溯并尝试其他路径,直到遍历完整个数据结构。

暴力搜索的优点是简单且通用性好,无须对数据做预处理和借助额外的数据结构。

但是,此类算法的时间复杂度为O(n) ,数据量越大,性能劣化越明显。

自适应搜索

自适应搜索指利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。

除了上面介绍的“二分查找”,自适应类型的搜索还有:

  • 哈希查找:利用哈希表将搜索数据和目标数据建立为键值对映射,实现查询操作。
  • 树查找:在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。

自适应搜索的优点是效率高,时间复杂度可达到 O(logn) 甚至 O(1)

然而,使用这些算法往往需要对数据进行预处理。

例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开销。

鉴于以上,实现搜索类需求不是没有方案,而是方案很多,这就引出了方案选择的问题。

方案选择

评定算法优劣的维度通常分为:时间和空间。

但具体到实际需求,还取决于规模、性能要求、数据查询与更新频率等。

使用哪一种需要根据方案的特点来定夺,简要介绍供参考:

线性搜索: 通用性较好,无须预处理,适用体量较小,更新频率较高的数据。

二分查找: 数据量适中,有序,更新频率低。

哈希查找: 数据量适中,对查询性能要求高的无序数据。

树查找: 海量,有序,范围查找。

小结

搜索需求应用广泛,可能涉及的数据量级和数据结构都会不同,没有通解,做决策需要一定知识广度。

本篇只作为引子,有个大概的认识,各位在项目中落地仍要分门别类进行拓展,一起加油!~

更多好文第一时间接收,可关注公众号:“前端说书匠”

轻松接入大语言模型API -04

2026年3月1日 19:46

前言

想在自己的应用中接入 AI 能力,但不知道从哪里开始?

云端 API 是最简单的切入点。无需本地算力,无需复杂配置,只需几行代码,就能让 GPT-4、Qwen、DeepSeek 等大模型为你所用。

今天我们来学习如何使用云端 LLM API,开启你的 AI 开发之旅。


1. 什么是云端 API

云端 API = 通过互联网调用大模型服务

┌─────────────┐                    ┌─────────────┐
│  你的应用   │──── 互联网请求 ────→│  云端 LLM   │
│             │←───────────────────│   服务     │
└─────────────┘    返回生成结果    └─────────────┘

云端 vs 本地

特点 云端 API 本地部署
硬件要求 无需本地算力 需要显卡/内存
使用成本 按调用量付费 一次部署,无限使用
数据隐私 数据上传云端 完全私密
网络依赖 需要网络 可离线使用
上手难度 简单 需要配置

2. 案例

案例 1:主流 API 平台对比

国内平台

平台 模型 价格 核心优势
阿里云百炼 Qwen 系列 ¥0.0008/1K tokens 中文优化,稳定可靠
DeepSeek DeepSeek-V3 ¥1/1M tokens 性价比之王
智谱 GLM GLM-4 按调用量计费 国产化,清华技术
百度文心 ERNIE 系列 按调用量计费 中文能力强

国际平台

平台 模型 价格 核心优势
OpenAI GPT-4 $5/1M tokens 综合最强
Anthropic Claude $3/1M tokens 长文本优秀
Google Gemini 按用量计费 多模态强

案例 2:阿里云百炼 API 使用流程

Step 1:申请 API Key

  1. 访问 阿里云百炼平台
  2. 登录阿里云账号
  3. 进入「API-KEY 管理」
  4. 创建新的 API Key
  5. 重要:复制并保存 Key(只显示一次)

Step 2:了解支持的模型

qwen-max        # 旗舰模型,最强能力
qwen-plus       # 通用模型,性价比高
qwen-turbo      # 高速模型,快速响应(推荐新手)
qwen-long       # 长文本模型,支持 100K+ tokens

Step 3:API 调用示例

const response = await fetch('https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${yourApiKey}`
  },
  body: JSON.stringify({
    model: 'qwen-turbo',
    messages: [{ role: 'user', content: '你好,请介绍一下你自己' }]
  })
});

const data = await response.json();
console.log(data.choices[0].message.content);

案例 3:DeepSeek API 的极致性价比

为什么选择 DeepSeek?

  • 价格极低:输入 ¥0.001/千 tokens,输出 ¥0.002/千 tokens
  • 性能优秀:接近 GPT-4 水平
  • 中文友好:专为中文优化
  • 兼容性好:完全兼容 OpenAI API 格式

调用示例

const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${yourApiKey}`
  },
  body: JSON.stringify({
    model: 'deepseek-chat',
    messages: [{ role: 'user', content: '请用 Python 写一个快速排序' }]
  })
});

案例 4:流式响应实现

让 LLM 逐字输出,提升用户体验:

async function streamChat(question: string) {
  const response = await fetch(apiUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${apiKey}`
    },
    body: JSON.stringify({
      model: 'qwen-turbo',
      messages: [{ role: 'user', content: question }],
      stream: true  // 启用流式响应
    })
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    const chunk = decoder.decode(value);
    // 实时处理每个数据块
    console.log(chunk);
  }
}

案例 5:参数调优

const response = await fetch(apiUrl, {
  method: 'POST',
  body: JSON.stringify({
    model: 'qwen-turbo',
    messages: messages,

    // 参数控制
    temperature: 0.7,    // 0-1,越高越随机
    max_tokens: 2000,     // 最大输出长度
    top_p: 0.9           // 核采样
  })
});

参数说明

参数 范围 效果 推荐值
temperature 0-1 控制随机性 0.7
max_tokens 1-∞ 限制输出长度 根据需求
top_p 0-1 核采样 0.9

案例 6:成本估算与优化

成本计算

场景:每天 100 次对话
每次平均:输入 100 tokens + 输出 500 tokens

每日成本(qwen-turbo):
输入:100 × 100 × 0.0008 / 1000 = ¥0.008
输出:100 × 500 × 0.002 / 1000 = ¥0.1
总计:¥0.108/天

每月成本(30天):¥3.24

节省成本技巧

  1. 优化 Prompt:减少不必要的上下文
  2. 使用缓存:对相同问题使用缓存
  3. 选择合适模型:简单任务用小模型
  4. 控制输出长度:使用 max_tokens 参数

案例 7:错误处理最佳实践

async function safeChat(question: string) {
  try {
    const response = await axios.post(apiUrl, {
      model: 'qwen-turbo',
      messages: [{ role: 'user', content: question }]
    }, {
      timeout: 30000  // 30 秒超时
    });

    return response.data;
  } catch (error) {
    if (error.response) {
      // 服务器返回错误
      console.error('API Error:', error.response.data);
    } else if (error.request) {
      // 请求发送但没有响应
      console.error('Network Error:', error.message);
    }
    throw error;
  }
}

3. 总结

需求 推荐平台 理由
国内用户 阿里云百炼 延迟低,中文好
成本敏感 DeepSeek 价格最低
质量优先 GPT-4 / Claude 综合最强
中文优化 Qwen / DeepSeek 专为中文优化

Star Trek : Captain's Chair 初体验

作者 云风
2026年3月1日 19:24

今年过年,我沉迷于 Star Trek : Captain's Chair 这款 2025 年的桌游。暂时还没有中文版,如果直译的话,名为《星际迷航:船长之椅》。这是一款以卡牌构筑为核心玩法的桌游,在游戏过程中,不断完善自己的牌堆,构筑一个高效的得分引擎。如果能比对手获得更多的 VP 就可以获得游戏胜利,但也要避免突然死亡。这是一款新游戏,但作者 Nigel Buckle 和 David Turczi 之前已经用类似的系统出过 Imperium (帝国)三部曲。其中《帝国:经典版》和《帝国:传奇版》有中文版,在淘宝上就可以买到。btw, 前段时间我玩过的 VoidFall 也是他们的作品。

这个游戏的规则还是挺复杂的,在 BGG 上的 weight 评级达到了 4.06 。注:游戏的重度(weight)是由玩家评分综合而来,最高为 5 。它指的是规则的繁杂程度,而并非游戏的策略深度(通常有相关性)。例如围棋虽然策略深度几乎达到了桌游的天花板,但它的 weight 就不到 4 。而 bgg 上 weight 超过 4 的游戏并不多见,大部分超过 3 的桌游,一般就被归为重度游戏了。我大概花了 10 多个小时试玩,看了几个小时的教学视频,才感觉学会了游戏的基本规则。不过一旦理解了游戏的设计逻辑,玩起来还颇为流畅,规则书以及规则助记版都非常符合直觉,简单好认。重度游戏大多不太讨人喜欢,但设计良好的重度游戏也能带来更多乐趣。

我认为 ST:CC 是我这些年玩过的所有卡牌构筑类桌游中机制、策略和局势变化最丰富的。它提供了及其丰富的机制让玩家控制牌组的构成,这也是“构筑”这个机制的核心玩点。和最早的《Dominion 领土》作比较:这类游戏的基本玩法就是从市场购买新卡,构建一个得分引擎。分往往也体现在牌组中,但会稀释行动牌的价值(通常分卡在游戏过程中没有收益),让玩家在构筑过程中做出权衡。Dominion 每局游戏的后期通常会面对厚厚的牌堆,行动会变得越来越不可预测。后来的同类游戏逐步加入了更丰富的机制来帮助卡组瘦身,提供给玩家更多确定性,更好的控制自己的行动。

ST:CC 以及它的前身 Imperium 提供了非常丰富的卡组瘦身机制:

  1. 可以把卡堆里的牌 LOG 起来:和早期卡牌构筑游戏不同,得分卡并不是专门的卡,而是每张卡本身就带有 VP 。这更像银河竞逐这样的引擎构建游戏。收集的卡越多得分越高。LOG 可以把当局游戏不再用的卡从当前卡堆里移除,但得分依旧保留。

  2. 可以把卡片 deploy 到桌面:放在桌面的卡可以提供持久的被动能力,也可以有限的提供主动能力或响应能力。同时,活动卡组也得到的瘦身。根据卡片属性不同,提供有差异的回收规则。船员卡可以常驻一张,新的船员卡晋升后 dismiss 旧的;飞船卡则在占领星球后自动 dismiss ;事件卡则每张有不同的回收前置行动(不回收会在游戏结束结算时计为负分)。

  3. 卡片可以 beam 到飞船或星球上:这可以对卡组作更灵活的临时瘦身。几乎所有的飞船都有主动能力可以 beam 手牌,但反向回到手牌的 recall 操作却比较稀少。不过,beam 在飞船上的卡也可以随飞船 dismiss 而一同回到弃牌堆。

永久(不可逆)和临时(可收回)的卡牌瘦身操作,可以让玩家在游戏过程中动态的调整卡组,让游戏的确定性更高,而不会在抽牌堆太大时,过于依赖抽卡的手气。就我这几天玩的数盘游戏体验,通常我的活动牌组(抽牌堆加上弃牌堆和手牌)在整局游戏里也很少超过 20 张。

ST:CC 在游戏过程中的卡组升级也有新意。

首先,和大多数卡牌构筑游戏一样,初始卡组是 10 张左右,每轮抽 5 张。这样可以保证前两轮可以作一个轮回,让随机性限制在 10 张卡的不同组合上。但和之前的很多游戏不同,它的 10 张卡是完全不同的,每张都特别设计过。甚至游戏带了 6 套风格迥异的初始牌组。而传统上的设计更偏好在初始卡组中放上雷同的初始能力卡,加上很少量的特殊卡。如果没玩过桌游的话,可以对比杀戮尖塔这样受桌游启发的电子游戏:一开始的初始卡组中只有一张特殊能力卡加上普通的打击和防御。

而和一般的卡牌构筑游戏的升级流程不同,它会为每个初始牌组设计 5 张左右的固定补充卡堆和 8 张左右的高级补充卡,以固定节奏补充进来:每次抽牌堆抽空都会自动触发这个补充操作,加入一张额外的补充卡。基本的补充卡的随机性在于每局游戏的进入次序是打乱的,而高级卡则需要用不同资源购买,但可以让玩家指定(没有抽卡的随机环节)。这样熟练牌组的玩家可以预先学习好每个角色牌组的策略,再实际玩的时候又不至于形成太固定的套路。

游戏依然提供公共市场和供双方争夺的中立地点卡。但很多传统的市场机制是用资源从市场买卡,而 SC:CC 并不通过积累资源购买市场卡,而是改为用特定行动卡片直接获取。市场被分为了四类:船员、货物、飞船、盟友,分别对应不同的行动卡去获取。根据选择的初始牌组不同,获取这些市场卡的行动卡使用方式也不一样。由于行动力有限,规划行动的分配获取市场卡就变成了卡片 combo 重要的一环。玩家很难积累获取市场卡的能力,抢夺地点卡更是这样:规则限制了每个回合最多只能获得一个中立地点。整局游戏中不会获得太多的额外卡片,且每张公共卡都是单独设计的,这让引入每张卡到自己的卡组都需要仔细规划。

ST:CC 的卡片被设计成一卡多用。卡片处于不同位置:从手牌打出或桌面上激活会有不同的能力。而即使是同一种方式使用它,一般也有多种能力供选择,只能选其一使用。虽然每种使用方式大多有前置条件或副作用,但本身的多种选择让每张卡片在不同场景下都有用。

因为卡片的位置非常丰富:除了传统的抽牌堆、弃牌堆、手牌外,还有桌面区、市场区、当前市场、市场库存、中立地、废牌堆、附着在其它卡片上、LOG 区、升级区、事件区等等。就我主要玩的 PICARD 牌阵来说,大量的行动就是将卡在这些这些区域之间调度。所以在玩的时候,有一点工人分配游戏的感觉。不仅提供了丰富的卡牌策略,还非常好的契合了星际迷航那种驾驶飞船探索宇宙的主题。


为了让游戏不限于千篇一律的构建得分引擎循环,游戏给每个牌组都设计了不同主题的任务。任务不同于很多引擎构建游戏的终局任务卡,那个在 ST:CC 里也有,被设计为 Encouter 卡片,通常可以提供大笔的 VP 。任务就是固定在每个初始卡组上的,像是堆每组不同风格的牌作一个游戏引导,引导在游戏过程中侧重某种玩法。例如,PICARD 的基本任务就是获得三张同盟卡,并把他们都 beam 到同一艘飞船上,且获得至少 4 点科技点和 4 点影响力,就可以完成。

这个设计不会让玩家(熟悉后)玩游戏时不会走一步看一步,每步寻找当下行动的利益最大化。玩家必须作一个长远规划:因为任务必不可少的需要分成很多步骤,同步相当多的行动在好几个回合才可能达成。以我玩的经验来看,基本任务一般在游戏中后期才可以达成,而以开始不作计划的话,常常忙到快结束时还差上一点点。

由于只靠固定牌组很难有效的完成任务,随机出现的公共牌加入卡组都能带来意想不到的高效组合,所以每局游戏的过程都会差异很大。我用 PICARD 玩了 3,4 局游戏,都选的 KOLOTH 这个 bot ,但每局游戏体验完全不同。更别说换掉对手会有完全不同的局面。游戏为每个舰长的 bot 定制了不同的自动化策略来模拟人类玩家选择不同舰长会出现的不一样的打牌倾向。

这是一个两人对战游戏,但也可以用设计好的自动化规则来模拟一个对手。但在 BGG 上,大多数玩家认为这个单人对抗 bot 的玩法更好玩。游戏的教学作的不错,提供了一个更存粹没有对手的单人模式,通常用于熟悉牌组。这个教学模式就是无干扰的刷分,刷够足够的分就胜利了。通过玩这个模式,可以体验不同舰长牌风格迥异的 combo 策略。通常建议把 6 个舰长都刷够分,这样在对战时既能知道自己应该怎么玩,还能熟知对手的策略。

正式的单人模式是对抗固定规则的对手。采用的是不对称规则:玩家和 bot 的行动法则是不一样的。我没有玩过对战模式,但据 BGG 论坛玩家的反馈,预设规则把和真人玩家的对抗时会产生的交互:争夺市场卡片、抢占中立地点等模拟的很好。一开始玩的时候,操作 bot 很容易出错,但玩过一盘之后就非常顺畅了,bot 每个回合一两分钟就能操作完,反之自己这边的行动每个回合会花很长时间。可想而之,和人对战应该会有极大的 downtime ,怪不得大多数人都选择了单人 solo 。

但我觉得,如果有个人类对手和自己一样玩过很多盘 solo 的话,再在一起对战应该也是非常有趣的。

官方还为单人模式设计了一个长线的五年计划规则。让玩家可以连着玩 5~10 盘游戏,在每盘游戏间加入了牌组升级:每次胜利都可以加入当局游戏终局时的某张市场卡进入初始牌组,或是 boost 一些初始能力。由于游戏设计了 6 组不同的牌,这相当于需要击败 5 个不同的对手(自动化 bot ),想来不会有太多重复感。我打算熟悉玩所有卡组后就尝试一下这个长线任务,应该会很有趣。


很想买一套实体版,但在淘宝上找不到代购,甚至目前美国那边也缺货等着重印。我这几天都是在桌面模拟器上玩的(有玩家制作的 mod )。我的感觉是,由于电子版缺少触感,细节更容易玩错。即使很熟悉后,游戏效率还是比不上实体。这点和版图游戏颇为不同,这个几乎全部用卡牌作道具,假若是实体牌的话,电子版只在洗牌时会便利一点,打牌及查看牌面要麻烦很多。而很多版图游戏,电子模拟器在 setup 以及游戏过程中的摆放都会更方便。

作为 solo 游戏,实体版最方便的地方在于易于反悔。只要没有信息揭示环节(例如抽牌后查看),大多数行动你都可以方便的在牌桌上 undo ,尝试各种不同的组合。电子模拟器上的 undo 操作一不小心就把桌面状态弄乱了。毕竟桌游除了桌面,人脑里还有一整套游戏状态,缺少实体会让大脑负荷要重得多。


谈点体外话。由于这款游戏规则相对繁杂,我尝试用 AI 辅助学习游戏规则,使用的 Gemini 。可惜这个游戏还太新,网上资料太少。导致 Gemini 对游戏规则细节知之甚少。但它又表现得很懂,对话中自信满满。我问了很多规则细节结果都是错的,即使我让它指出细节出至规则书上具体哪里,也全是幻觉。甚至引用论坛网友的讨论也能理解错误。最后,我还是得自己推敲规则书,或是用传统的搜索方法找到 bgg 论坛规则讨论版面的帖子,研读作者写的 FAQ 等等。和 AI 的问答阅读起来固然舒服,针对性很强(不像规则书读起来那么累),但我实在没有能力鉴别 AI 的错误。毕竟我原本就是因为不懂规则才去问的呀。

有些错误还是能看出来。毕竟我玩的游戏很多,可以从作者的游戏设计思路角度去考虑。玩的过程中有疑问去问 AI 。对反直觉(感觉游戏不应该这样设计)的答案有所警惕,可继续追问。但有些真看不出来。

比如我在和 bot 对战时,触发了一条 bot 需要 log 一艘飞船,我不知道该如何处理。(特地用英文术语)问了下 AI 。AI 告诉我应该把最近 bot 部署的 ship 卡 log 起来,并将同一地区的所有外派部队收回。但后一条是 AI 自己编的规则,我在规则书中怎么都找不到对应的文字。反复询问,AI 都表现的信誓旦旦。让我去查规则书某个章节(其实不存在)。它还引用了 BGG 论坛的帖子。而我仔细研读了大篇的帖子后,确定是 AI 混淆了 log 和 dismiss 的处理方法。

结果,我和 AI 的这番对话并没有帮我节省理解规则的时间。不仅自己重新反复研究规则书,还花了更多时间去论坛看帖(当然这不是坏事)。我想,如果我让一个人类游戏玩家教我,若是自己没怎么玩这个游戏的话,都不会表现的如此自信吧。如何辨别 LLM 提供的信息中哪些确有价值会变得更加重要。LLM 的语言表达能力越来越强,也会变得越来越有欺骗性。

基于 Lexical 实现变量输入编辑器

2026年3月1日 18:51

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:霁明

1. 引言

1.1 背景与动机

在 AIWorks 的工作流和 Agent 编排系统中,有一个核心需求:支持在节点配置面板的配置项中引用上游节点的输出变量。例如,一个 LLM 节点需要引用“开始节点”的用户输入或自定义变量,或者引用上一个“HTTP 请求节点”的返回结果。

最直接的方案是使用传统的 Input 或 Textarea 组件,配合变量占位符语法如 {{nodeId.variableName}}。但这种方案存在明显的用户体验问题:

  • 可读性差:原始的变量语法对用户不友好,难以快速识别变量来源
  • 输入效率低:用户需要记忆变量名称和语法格式
  • 缺乏上下文:无法直观展示变量所属节点和类型
  • 易出错:手动输入变量语法容易出现拼写错误

我们期望的用户体验是:

  1. 用户输入 / 字符时,自动弹出变量选择菜单
  2. 菜单按节点分组展示所有可用变量,支持搜索过滤
  3. 选择变量后,以可视化标签的形式展示(显示节点图标、节点名称、变量名)
  4. 底层数据仍保持 {{#nodeId.variableName#}} 格式,便于后端解析

1.2 最终效果

实现后的效果如下:

Lexical 变量输入编辑器 Jan 6 2026.gif

  • 触发菜单:在编辑器中任意位置输入 /,即刻弹出变量选择悬浮菜单
  • 变量搜索:支持按变量名进行搜索
  • 可视化标签:选中的变量渲染为带有节点图标和样式的标签
  • 无缝编辑:标签与普通文本混排,支持 Input 组件中的常规操作,例如复制、删除、撤销等

2. 技术选型:为什么选择 Lexical?

2.1 Lexical 简介

Lexical 是 Meta(Facebook)于 2022 年开源的一个可扩展的可扩展富文本编辑器框架,它专注于提供高可靠性、出色的可访问性和高性能,让开发者能构建出从简单文本到复杂富文本协作编辑器的应用。它核心是一个轻量、无依赖的编辑器,通过模块化的插件机制支持自定义功能,支持与 React 等前端框架进行绑定,旨在简化富文本编辑器的开发和维护。

2.2 主流富文本框架对比

维度 Lexical Slate Tiptap ProseMirror Editor.js Quill
维护方 Meta 社区 Tiptap 团队 社区 CodeX 团队 社区
是否开源 是 (MIT) 是 (MIT) 是 (MIT) 是 (MIT) 是 (Apache 2.0) 是 (BSD)
React 支持 原生 原生 支持 需适配层 支持 支持
学习曲线 中等 中等偏高 中等偏低 陡峭
社区生态 增长迅速 稳定 繁荣 稳定 稳定 稳定
TS 支持 完善 完善 完善 支持 支持 支持
核心优势 高可靠性、高性能、Meta 背书,适合现代 web 应用 灵活性极高、符合 React 直觉 兼顾易用与强大、UI 无头 协同编辑天花板、极其严谨 块级结构、天然适合 CMS 简单易用、稳定
主要劣势 文档仍可优化 升级可能断层 协作/高级功能需付费订阅 开发门槛极高 跨行选择等体验有限 定制复杂功能较难
适用场景 现代高性能 React 应用 需要极度定制 UI 的 React 项目 快速交付的产品 复杂协同办公 (Google Docs 类) 新闻发布、类 Notion 编辑器 评论区、简单博客、CMS

2.3 选择 Lexical 的理由

  1. 轻量级:核心库约 42KB(gzip 后),对 bundle size 友好
  2. 现代架构:基于不可变状态,与 React 理念一致
  3. 高性能:优化的内部机制使得能够处理大规模的文本编辑任务而不牺牲响应速度
  4. 强扩展性:插件化设计,自定义节点类型简单直观
  5. React 深度集成:虽然并不仅限于 React,但它提供了与 React 深度集成的能力
  6. 官方维护:Meta 活跃维护,稳定可靠
  7. TypeScript 原生:完整的类型支持,开发体验好
  8. 同类主流产品验证:Dify、FastGPT 等都采用 Lexical 实现变量输入功能

2.4 AIWorks 使用的依赖

{
  "lexical": "^0.35.0",
  "@lexical/react": "^0.35.0",
  "@lexical/text": "^0.35.0",
  "@lexical/utils": "^0.35.0"
}
  • lexical:核心库,提供编辑器状态管理、节点系统、命令系统
  • @lexical/react:React 绑定,提供 Composer、插件等组件
  • @lexical/text:文本处理工具,包含文本实体(Text Entity)相关功能
  • @lexical/utils:工具函数,如 mergeRegister 用于批量注册/注销

3. Lexical 核心概念速览

在深入实现之前,我们需要理解 Lexical 的几个核心概念。

3.1 编辑器状态

Lexical 采用不可变状态设计。编辑器的所有内容都存储在 EditorState 中,任何修改都会产生新的状态对象。

// 读取状态(只读操作)
editor.getEditorState().read(() => {
  const root = $getRoot();
  const text = root.getTextContent();
});

// 更新状态(写操作)
editor.update(() => {
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    selection.insertText('Hello');
  }
});

关键点

  • read() 内只能读取,不能修改
  • update() 内可以读取和修改
  • 所有 $ 开头的函数(如 $getRoot$getSelection)只能在这两个回调中调用

3.2 节点体系

Lexical 的内容由树状节点结构组成:

RootNode
  └── ParagraphNode (ElementNode)
        ├── TextNode ("普通文本")
        ├── VariableLabelNode (DecoratorNode) 
        └── TextNode ("更多文本")

核心节点类型:

类型 说明 示例
RootNode 根节点,每个编辑器有且仅有一个 -
ElementNode 容器节点,可包含子节点 ParagraphNode, ListNode
TextNode 文本叶子节点 普通文本内容
DecoratorNode 装饰器节点,可渲染自定义 React 组件 变量标签、提及、表情

DecoratorNode 是实现自定义可视化元素的关键,后文会详细讲解。

3.3 命令系统

Lexical 使用命令模式处理用户输入和操作:

// 创建自定义命令
const HELLO_WORLD_COMMAND: LexicalCommand<string> = createCommand();

// 注册自定义命令行为
editor.registerCommand(
  HELLO_WORLD_COMMAND,
  (payload: string) => {
    console.log(payload);
    return false;
  },
  COMMAND_PRIORITY_LOW,
);

// 触发对应命令
editor.dispatchCommand(HELLO_WORLD_COMMAND, 'Hello World!');

Lexical 内置了许多命令,例如:KEY_DOWN_COMMAND、UNDO_COMMAND、INSERT_TAB_COMMAND 等,具体可查看LexicalCommands.ts

命令优先级从高到低:

  • COMMAND_PRIORITY_CRITICAL (4)
  • COMMAND_PRIORITY_HIGH (3)
  • COMMAND_PRIORITY_NORMAL (2)
  • COMMAND_PRIORITY_LOW (1)
  • COMMAND_PRIORITY_EDITOR (0)

3.4 节点转换

节点转换是 Lexical 的强大特性,允许监听特定类型节点的变化并自动处理:

editor.registerNodeTransform(TextNode, (textNode) => {
  // 每当 TextNode 发生变化时触发
  const text = textNode.getTextContent();
  
  // 检测特定模式并转换
  if (isVariablePattern(text)) {
    const variableNode = $createVariableLabelNode(...);
    textNode.replace(variableNode);
  }
});

这是实现“输入特定文本自动转换为自定义节点”的核心机制。

3.5 插件架构

Lexical 采用组合式插件设计:

<LexicalComposer initialConfig={config}>
  {/* 核心编辑插件 */}
  <RichTextPlugin contentEditable={...} placeholder={...} />
  
  {/* 功能插件 */}
  <HistoryPlugin />           {/* 撤销/重做 */}
  <OnChangePlugin />          {/* 内容变化监听 */}
  <VariableLabelPlugin />     {/* 自定义:变量渲染 */}
  <VariableLabelPickerPlugin />{/* 自定义:变量选择 */}
</LexicalComposer>

插件通过 useLexicalComposerContext() 获取编辑器实例:

const MyPlugin = () => {
  const [editor] = useLexicalComposerContext();
  
  useEffect(() => {
    // 使用 editor 注册命令、转换等
  }, [editor]);
  
  return null; // 无 UI 的纯逻辑插件
};

4. 整体架构设计

4.1 架构图

Lexical 变量输入编辑器.png

4.2 组件职责划分

组件/模块 职责
PromptEditor 业务组件,连接 workflow store,处理多行提示词场景
VariableEditor 业务组件,处理单行变量输入场景
Editor 核心组件,封装 Lexical 编辑器和所有插件
VariableLabelNode 自定义节点,渲染为 React 组件,用于反显变量标签
VariableLabelPlugin 自定义插件,监听文本变化,将变量语法转换为变量标签
VariableLabelPickerPlugin 自定义插件,处理 / 触发和变量选择
SingleLinePlugin 自定义插件,限制单行输入

4.3 数据流及渲染过程

flowchart TD
Start([开始]) --> Input["用户输入 '/'"]
Input --> Detect["VariableLabelPickerPlugin 检测到 '/'"]
Detect --> Menu["弹出 VariableMenu 菜单"]
Menu --> Select["用户选择变量"]
Select --> Insert["插入文本 '{{#nodeId.varName#}}'"]
Insert --> Transform["VariableLabelPlugin 的 TextNode Transform 检测到变量语法"]
Transform --> CreateNode["创建 VariableLabelNode 替换文本"]
CreateNode --> Render["VariableLabelNode 渲染 VariableLabel 组件"]
Render --> Sync["OnChangePlugin 的 onChange 方法触发,同步文本内容到外部状态"]
Sync --> End([结束])

5. 核心实现详解

5.1 自定义 VariableLabelNode

这是整个方案的核心。我们通过继承 DecoratorNode 来创建一个可以渲染 React 组件的自定义节点:

export class VariableLabelNode extends DecoratorNode<JSX.Element> {
  __variableKey: string;      // 变量的完整标识,如 {{#nodeId.name#}}
  __variableLabel: string;    // 显示用的标签
  __isSystemVariable: boolean; // 是否为系统变量

  static getType(): string {
    return "variableLabel";
  }

  // 返回 React 组件作为节点的渲染内容
  decorate(): JSX.Element {
    return (
      <VariableLabel
        variableLabel={this.__variableLabel}
        isSystemVariable={this.__isSystemVariable}
      />
    );
  }
  // ... 其他方法
}

关键设计点:

  1. 继承 DecoratorNode:这使得节点可以渲染任意 React 组件
  2. **getTextContent()**:返回变量的原始格式文本,确保序列化时能正确还原
  3. **decorate()**:返回 VariableLabel 组件,实现可视化展示

5.2 触发器:/ 唤起变量选择菜单

当用户输入 / 时,我们需要弹出一个变量选择菜单。这里使用 Lexical 官方提供的 LexicalTypeaheadMenuPlugin

const VariableLabelPickerPlugin = ({ variableGroups }) => {
  const [editor] = useLexicalComposerContext();

  // 自定义触发匹配:检测用户输入 /
  const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
    minLength: 0,
  });

  // 用户选择变量后的处理逻辑
  const onSelectOption = useCallback((selectedOption, nodeToRemove, closeMenu) => {
    editor.update(() => {
      // 删除触发字符 /
      if (nodeToRemove) nodeToRemove.remove();

      // 插入变量文本,格式为 {{#nodeId.variableName#}}
      selection.insertNodes([
        $createTextNode(`{{#${selectedOption.nodeId}.${selectedOption.name}#}}`),
      ]);
      closeMenu();
    });
  }, [editor]);
  // ...
};

工作流程:

  1. 用户输入 /checkForTriggerMatch 返回匹配结果
  2. 弹出 VariableMenu 组件,显示可用变量列表
  3. 用户点击选择 → onSelectOption 插入格式化的变量文本
  4. VariableLabelPlugin 监测到文本变化,自动转换为节点

注意这里我们并不直接插入 VariableLabelNode,而是插入格式化的文本字符串。这是为了解耦选择逻辑和渲染逻辑——文本到节点的转换由下一个插件统一处理。

5.3 文本实体识别与自动转换

VariableLabelPlugin 负责监听文本变化,当发现符合变量格式的文本时,自动将其转换为 VariableLabelNode

const VariableLabelPlugin = () => {
  const [editor] = useLexicalComposerContext();

  // 创建变量节点的工厂函数
  const createVariableLabelPlugin = useCallback((textNode: TextNode) => {
    const text = textNode.getTextContent();
    const info = parseVariableTokenInfo(text);
    return $createVariableLabelNode(
      text,
      info?.variableName ?? "",
      info?.isSystemVariable ?? false,
    );
  }, []);

  useEffect(() => {
    // 注册文本实体转换器
    registerLexicalTextEntity(
      editor,
      getVariableMatchInText,  // 正则匹配函数
      VariableLabelNode,
      createVariableLabelPlugin,
    );
  }, [editor]);
  // ...
};

变量格式通过正则表达式定义:

// 用户变量格式:{{#uuid.variableName#}}
export const USER_VARIABLE_REGEX = new RegExp(
  "(\\{\\{)(#)([a-fA-F0-9-]{36}\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);

// 系统变量格式:{{#system.xxx#}}
export const SYSTEM_VARIABLE_REGEX = new RegExp(
  "(\\{\\{)(#)(system\\.[a-zA-Z0-9_]+)(#)(\\}\\})",
);

registerLexicalTextEntity 是核心的转换逻辑,它注册了两个 Transform:

export function registerLexicalTextEntity(editor, getMatch, targetNode, createNode) {
  // 1. TextNode → VariableLabelNode 的转换
  const textNodeTransform = (node: TextNode) => {
    const text = node.getTextContent();
    const match = getMatch(text);
    if (match === null) return;

    // 分割文本节点,将匹配部分替换为目标节点
    const [nodeToReplace, remainingNode] = node.splitText(match.start, match.end);
    const replacementNode = createNode(nodeToReplace);
    nodeToReplace.replace(replacementNode);

    // 递归处理剩余文本(可能包含多个变量)
    if (remainingNode) textNodeTransform(remainingNode);
  };

  // 2. 反向转换:当节点内容不再匹配时还原为文本
  const reverseNodeTransform = (node) => {
    const match = getMatch(node.getTextContent());
    if (match === null) {
      replaceWithSimpleText(node);  // 还原为普通文本
    }
  };

  return [
    editor.registerNodeTransform(TextNode, textNodeTransform),
    editor.registerNodeTransform(targetNode, reverseNodeTransform),
  ];
}

5.4 变量标签的可视化渲染

VariableLabel 组件负责将变量以友好的方式呈现给用户:

const VariableLabel = ({ variableLabel, isSystemVariable }) => {
  const { Icon, nodeLabel, displayLabel } = useVariableLabelInfo(
    variableLabel,
    isSystemVariable,
  );

  return (
    <div className="inline-flex items-center rounded-sm bg-bg-primary-4 px-[2px]">
      <Icon className="flex-shrink-0" />
      <span className="text-text-2-icon">{nodeLabel}</span>
      <span className="text-text-4-description">/</span>
      <span className="text-primary-default">{displayLabel}</span>
    </div>
  );
};

会渲染一个可视化变量标签,包含节点图标、节点名称和变量名,效果如下:

5.5 编辑器单行模式

在某些场景(如 HTTP 节点的 URL 输入、条件节点的表达式输入),我们需要限制编辑器为单行模式:

const SingleLinePlugin = ({ onEnter }) => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    mergeRegister(
      // 1. 限制 RootNode 只保留一个段落
      editor.registerNodeTransform(RootNode, (rootNode) => {
        if (rootNode.getChildrenSize() <= 1) return;

        const children = rootNode.getChildren();
        const firstChild = children[0];
        // 将后续段落的内容合并到第一个段落
        for (let i = 1; i < children.length; i++) {
          const paragraph = children[i];
          paragraph.getChildren().forEach(child => firstChild.append(child));
          paragraph.remove();
        }
      }),

      // 2. 拦截 Enter 键
      editor.registerCommand(KEY_ENTER_COMMAND, (event) => {
        event?.preventDefault();
        onEnter?.();  // 可以触发外部回调,如提交表单
        return true;
      }, COMMAND_PRIORITY_HIGH),
    );
  }, [editor, onEnter]);

  return null;
};

这个插件通过两种机制实现单行限制:

  1. RootNode Transform:当检测到多个段落时,自动合并为一个
  2. Command 拦截:阻止 Enter 键创建新段落

5.6 编辑器状态初始化与同步

编辑器内容需要与后端数据同步,我们采用纯文本格式存储。

编辑器状态初始化:

export const textToEditorState = (text = "") => {
  const lines = text.split("\n");
  const paragraph = lines.map((p) => ({
    children: [{ text: p, type: "text", ... }],
    type: "paragraph",
    //...
  }));

  return JSON.stringify({
    root: { children: paragraph, type: "root", ... },
  });
};

编辑器状态同步:

const handleEditorChange = (editorState: EditorState) => {
  const text = editorState.read(() => {
    return $getRoot()
      .getChildren()
      .map((p) => p.getTextContent())
      .join("\n");
  });
  onChange(text);
};

由于 VariableLabelNode.getTextContent() 返回原始变量格式({{#nodeId.name#}}),导出的文本可以直接存储,再次加载时会自动转换回节点形式。

6. 总结

本文介绍了基于 Lexical 实现工作流变量输入编辑器的完整方案:

  1. VariableLabelNode:继承 DecoratorNode 实现渲染自定义变量标签节点
  2. VariableLabelPickerPlugin:使用 LexicalTypeaheadMenuPlugin 实现 / 触发展示变量选择菜单
  3. VariableLabelPlugin:通过 Transform 自动识别和转换变量文本
  4. SingleLinePlugin:可选的单行模式支持
  5. 插件化架构:功能解耦,各插件职责单一,方便维护和扩展

这套方案适用于:

  • 工作流中的变量引用
  • 类似评论区的 Mention 功能
  • 模板引擎的可视化编辑
  • 任何需要“触发字符 + 选择菜单 + 自定义渲染”的场景

最后

欢迎关注【袋鼠云数栈 UED 团队】~ 袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

从零构建一个现代登录页:深入解析 Tailwind CSS + Vite + Lucide React 的完整技术栈

作者 AAA阿giao
2026年3月1日 18:25

引言

在当今前端开发的快节奏世界中,开发者们不再满足于“能用”的界面,而是追求高效、美观、可维护且体验流畅的 UI。而要实现这一目标,一套现代化的技术组合至关重要。

本文将带你从零开始,使用 ViteTailwind CSSLucide React 构建一个专业级的登录页面,并对每一行代码、每一个 Tailwind 工具类进行逐层拆解与深度解析。我们将不仅告诉你“怎么写”,更要解释“为什么这样写”、“背后原理是什么”、“如何举一反三”。

📌 核心目标:让你彻底掌握 Tailwind CSS 的思维方式,理解现代 React 应用的工程结构,并能独立构建高保真、响应式、交互丰富的用户界面。


第一部分:技术选型 —— 为什么是 Vite + Tailwind + Lucide?

Vite:下一代前端构建工具

Vite 由 Vue.js 作者尤雨溪打造,利用原生 ES 模块(ESM)和浏览器对 import 的原生支持,实现了闪电般的冷启动速度毫秒级热更新。它摒弃了传统打包器(如 Webpack)在开发时“先打包再运行”的模式,转而采用“按需编译”,极大提升了开发体验。

对于新项目,官方推荐使用:

npm create vite@latest my-project -- --template react

Tailwind CSS:原子化 CSS 的革命者

Tailwind CSS 不是一个组件库,而是一个 Utility-First(实用优先) 的 CSS 框架。它提供数千个低层级的 CSS 类(如 p-4text-centerbg-blue-500),让你直接在 HTML/JSX 中组合出任意设计。

💡 关键理念“你不需要写一行自定义 CSS,就能构建完全定制化的 UI。”

优势包括:

  • 开发速度极快:所见即所得,无需切换文件。
  • 天然响应式md:p-10 这样的前缀让适配屏幕轻而易举。
  • 自动 Purge(Tree-shaking) :只打包你实际使用的类,生产包体积极小。
  • 主题一致性:所有颜色、间距、圆角都来自同一套设计系统(Design Token)。

Lucide React:轻量、类型安全的 SVG 图标库

LucideFeather Icons 的社区驱动继任者,提供超过 1000 个精心设计的开源图标。其 React 版本 lucide-react 具备以下优点:

  • 每个图标都是独立的 React 组件,支持 TypeScript。
  • 完全 tree-shakable:只打包你导入的图标。
  • 高度可定制:通过 sizecolorstrokeWidth 等 props 控制外观。
  • 渲染为内联 SVG:无额外 HTTP 请求,性能优异。

安装命令:

pnpm add lucide-react

第二部分:工程搭建 —— 零配置集成 Tailwind 到 Vite

根据 Tailwind 官方 Vite 安装指南,我们只需四步:

步骤 1:创建 Vite 项目(如果尚未创建)

npm create vite@latest tailwindcss-login -- --template react
cd tailwindcss-login

步骤 2:安装依赖

npm install tailwindcss @tailwindcss/vite

⚠️ 注意:这里使用的是 @tailwindcss/vite 插件,这是 Tailwind v4 推出的新方式,无需 PostCSS 配置,简化了集成流程。

步骤 3:配置 Vite

编辑 vite.config.ts(或 .js):

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()],
})

步骤 4:引入 Tailwind CSS

在你的主样式文件(如 src/index.css)中添加:

@import "tailwindcss";

然后在 main.jsxApp.jsx 中确保该 CSS 被引入。

步骤 5:启动开发服务器

npm run dev

✅ 恭喜!你现在可以在任何组件中自由使用 Tailwind 的所有工具类了。


第三部分:业务逻辑 —— React 状态与受控组件

在 React 中,表单的最佳实践是使用 受控组件(Controlled Components) —— 即表单元素的值由 React 的 state 驱动,而非 DOM 自己管理。这确保了 UI 与数据状态始终保持同步。

核心状态定义

const [formData, setFormData] = useState({
  email: '',
  password: '',
  rememberMe: false
})
  • emailpassword 是字符串,用于文本输入框。
  • rememberMe 是布尔值,用于复选框。

通用事件处理器

const handleChange = (e) => {
  const { name, value, type, checked } = e.target;
  setFormData((prev) => ({
    ...prev,
    [name]: type === "checkbox" ? checked : value
  }));
}
  • 使用 计算属性名 [name] 动态更新对应字段。
  • 区分 input(取 value)和 checkbox(取 checked)。

密码可见性切换

const [showPassword, setShowPassword] = useState(false);
// 在 input 的 type 中动态切换
type={showPassword ? "text" : "password"}

加载状态(预留)

const [isLoading, setIsLoading] = useState(false);

虽然当前 handleSubmit 是空的,但未来可在此处调用 API,并设置 setIsLoading(true) 来禁用按钮、显示 loading 动画等。


第四部分:深度解析 —— Tailwind 工具类全解

项目源码链接:react/tailwindcss-login/src/App.jsx · Zou/lesson_zp - 码云 - 开源中国

接下来,我们将逐层、逐类、逐像素地解析这个 UI 的构建逻辑。

1. 页面容器:撑满屏幕并居中

<div className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
类名 含义 技术细节
min-h-screen 最小高度 = 100vh 确保即使内容很少,页面也占满整个视口,避免“短页面”出现空白。
bg-slate-50 背景为浅灰蓝 slate-50 是 Tailwind 默认调色板中最浅的中性色,柔和不刺眼。
flex 启用 Flexbox 布局 现代布局的基石。
items-center 交叉轴(垂直)居中 子元素在垂直方向上居中。
justify-center 主轴(水平)居中 子元素在水平方向上居中。
p-4 内边距 1rem (16px) 为移动端提供安全边距,防止内容贴边。

📏 单位说明:Tailwind 的默认间距单位基于 0.25rem(4px)。所以 p-4 = 4 * 4px = 16px


2. 登录卡片:视觉焦点与层次感

<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8 md:p-10">
类名 含义 技术细节
relative z-10 相对定位 + 层级提升 为内部绝对定位元素建立上下文;z-10 确保卡片在背景之上(虽非必需,但良好习惯)。
w-full 宽度 100% 占满父容器(即 p-4 后的可用宽度)。
max-w-md 最大宽度 28rem (448px) 在大屏设备上限制宽度,避免文字行长过长影响阅读。
bg-white 纯白背景 bg-slate-50 形成对比,突出内容区域。
rounded-3xl 圆角 1.5rem (24px) 超大圆角,营造现代、友好的感觉。
shadow-xl 大阴影 对应 CSS: box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
shadow-slate-200/60 阴影颜色 + 透明度 将默认黑色阴影替换为 slate-200 并设 60% 透明度,更柔和自然。
border border-slate-100 1px 边框 slate-100 几乎是白色,在浅背景下提供微妙分隔线。
p-8 md:p-10 内边距响应式 手机: 2rem (32px);中屏及以上: 2.5rem (40px),提升桌面体验。

🌐 响应式前缀md: 表示“中等屏幕及以上”(默认断点 ≥768px)。Tailwind 采用 Mobile First 策略,所有类默认作用于最小屏幕,更大屏幕通过前缀覆盖。


3. 顶部图标与标题

<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
  <Lock size={24}/>
</div>
类名 含义
inline-flex 行内 Flex 容器
w-12 h-12 3rem × 3rem (48px × 48px)
rounded-xl 圆角 0.75rem (12px)
bg-indigo-600 品牌主色背景
text-white 白色文字/图标
shadow-lg shadow-indigo-200 发光效果

标题文字使用 text-slate-900(接近黑)和 text-slate-500(中灰),形成清晰的视觉层次。


4. 表单结构:间距与分组

<form className='space-y-6'>
  <div className="space-y-2">...</div>
</form>
  • space-y-6子元素之间垂直间距 1.5rem (24px)。这是 Tailwind 的 “间距组” 功能,避免手动写 margin-top
  • space-y-2:label 与 input 之间间距 0.5rem (8px)。

💡 原理space-y-N 会为除第一个子元素外的所有子元素添加 margin-top: N * 0.25rem


5. 输入框布局:绝对定位与交互反馈

每个输入框都被包裹在 relative group 中:

<div className="relative group">
  <!-- 左侧图标 -->
  <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
    <Mail size={18} />
  </div>
  <!-- 输入框 -->
  <input className="block w-full pl-11 pr-4 py-3 ..." />
</div>

定位系统

  • relative:为内部 absolute 元素建立定位上下文。
  • absolute inset-y-0 left-0:图标容器垂直拉满(top: 0; bottom: 0),贴左对齐。
  • pl-4:图标容器内部左填充 1rem (16px),控制图标与边界的距离。
  • pl-11(输入框):左填充 2.75rem (44px),为图标预留空间(图标约 18px + pl-4 ≈ 34px,留有余量)。

交互状态

  • pointer-events-none:禁止图标接收鼠标事件,避免点击图标时无法聚焦 input。
  • group-focus-within:text-indigo-600:当 .group 内任意子元素(如 input)获得焦点时,图标颜色变为品牌色。这是实现“聚焦高亮”的关键。
  • transition-colors:颜色变化时添加平滑过渡(默认 150ms ease)。

输入框自身样式

className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600 transition-all"
类名 作用
block 块级元素,独占一行
w-full 宽度 100%
py-3 上下内边距 0.75rem (12px),增大点击区域
pr-4 右内边距,为密码切换按钮留空间
bg-slate-50 浅灰背景,区别于白色卡片
border border-slate-200 极浅灰色边框
rounded-xl 12px 圆角
text-slate-900 深色文字,保证可读性
placeholder:text-slate-400 placeholder 文字为浅灰色(注意:这不是伪类,而是对 ::placeholder 的封装)
focus:outline-none 移除浏览器默认蓝色轮廓
focus:ring-2 添加 2px 宽的“环形阴影”(位于边框外)
focus:ring-indigo-600/20 ring 颜色为品牌色 + 20% 透明度,柔和高亮
focus:border-indigo-600 边框变品牌色,明确指示当前字段
transition-all 所有可变属性(颜色、边框、阴影)都启用过渡动画

🎯 伪类前缀:Tailwind 使用 hover:focus:group-focus-within: 等前缀来模拟 CSS 伪类。例如 focus:border-indigo-600 编译为:

.focus:border-indigo-600:focus {
  border-color: #4f46e5;
}

👁️ 6. 密码可见性切换

<button
  type="button"
  onClick={() => setShowPassword(!showPassword)}
  className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
  {showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
  • absolute inset-y-0 right-0:按钮垂直拉满,贴右对齐。
  • pr-4:内部右填充,控制图标与右边界的距离。
  • hover:text-slate-600:悬停时颜色变深,提示可点击。
  • 使用 EyeEyeOff 图标动态切换,直观表达状态。

7. “忘记密码?”链接

<a href="#" className="text-sm font-medium text-indigo-600 hover:text-indigo-500 transition-colors">
  忘记密码?
</a>
  • 使用品牌色 text-indigo-600 引导用户操作。
  • hover:text-indigo-500 提供悬停反馈。
  • ml-1(在父容器)微调左外边距,使对齐更精确。

第六部分:特别说明 —— 关于 placeholder 和伪类

虽然代码中使用了:

placeholder:text-slate-400

但这不是伪类,而是 Tailwind 对 ::placeholder 伪元素的直接封装。

真正的伪类组合示例(虽未使用):

focus:placeholder:text-indigo-500

表示“当 input 聚焦时,placeholder 文字变为 indigo-500”。

📚 伪类 vs 伪元素

  • 伪类:hover, :focus):描述元素的状态。
  • 伪元素::before, ::placeholder):创建不在文档中的虚拟元素。

Tailwind 对两者都提供了前缀支持,但语法略有不同。


第七部分:总结与展望

通过这个登录页,我们不仅实现了一个美观、响应式的 UI,更重要的是掌握了:

  1. 现代前端工程化流程:Vite + Tailwind 的零配置集成。
  2. 原子化 CSS 思维方式:用组合代替继承,用工具类代替手写 CSS。
  3. React 状态管理最佳实践:受控组件、通用事件处理。
  4. 高级布局技巧:Flexbox 居中、绝对定位嵌套、间距组。
  5. 交互细节打磨:聚焦高亮、悬停反馈、过渡动画、品牌色贯穿。
  6. 第三方库集成:Lucide React 的按需引入与定制。

下一步你可以做什么?

  • 添加表单验证:使用 react-hook-form + zod
  • 实现加载状态:在 handleSubmit 中设置 isLoading,并禁用按钮。
  • 抽象 Input 组件:将带图标的 input 封装为可复用组件。
  • 主题切换:利用 Tailwind 的 dark: 前缀实现暗色模式。
  • 国际化:使用 react-i18next 支持多语言。

结语

前端开发不再是“切图 + 写 CSS”的体力活,而是一门融合工程、设计与用户体验的艺术。Tailwind CSS 让你从繁琐的样式命名和调试中解放出来,专注于构建真正有价值的用户界面

正如 Tailwind 官方所说:

“You aren’t limited to the design you started with — you can customize everything.”

而今天,你已经迈出了第一步。

Happy coding! 🚀

国投白银LOF:3月2日开市起至当日10:30停牌

2026年3月1日 17:43
36氪获悉,国投瑞银基金公告称,旗下国投瑞银白银期货证券投资基金(LOF)A类基金份额二级市场交易价格明显高于基金份额净值,出现较大幅度溢价。为保护投资者利益,该基金将于2026年3月2日开市起至当日10:30停牌,10:30复牌,停牌期间赎回业务照常办理。若当日溢价幅度未有效回落,基金有权采取进一步措施警示风险。

天宜新材:预重整产业投资人遴选完成,紫光通信联合体为正选

2026年3月1日 17:41
36氪获悉,天宜新材公告,公司预重整产业投资人遴选已完成,确定紫光通信联合体中的北京紫光通信科技集团有限公司和全讯汇聚网络科技(北京)有限公司为正选产业投资人,泰州衡川新能源材料科技有限公司-北京深蓝重整咨询有限公司-青岛熙正盈启私募股权投资基金合伙企业(有限合伙)联合体中的产业投资人泰州衡川新能源材料科技有限公司为公司预重整备选产业投资人。

中国最大的食用油品牌居然不在国企央企手里?

2026年3月1日 17:00

复杂的竞争格局,让整个市场看起来是一幅勃勃生机,万物竞发的景象。而中国食用油市场的起点,居然源自央企中粮集团和糖王郭鹤年叔侄的一次不对等合作,导致中粮拱手把国内第一的位置让给了别人。


这期视频,枪仔尝试给大家梳理一下国内食用油品牌的前世今生,顺便盘一盘那些曾经影响全中国、成为全民讨论焦点的事件,如何影响中国食用油市场的。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

每日一题-十-二进制数的最少数目🟡

2026年3月1日 00:00

如果一个十进制数字不含任何前导零,且每一位上的数字不是 0 就是 1 ,那么该数字就是一个 十-二进制数 。例如,1011100 都是 十-二进制数,而 1123001 不是。

给你一个表示十进制整数的字符串 n ,返回和为 n十-二进制数 的最少数目。

 

示例 1:

输入:n = "32"
输出:3
解释:10 + 11 + 11 = 32

示例 2:

输入:n = "82734"
输出:8

示例 3:

输入:n = "27346209830709182346"
输出:9

 

提示:

  • 1 <= n.length <= 105
  • n 仅由数字组成
  • n 不含任何前导零并总是表示正整数

JS执行机制、作用域及作用域链

作者 亦妤
2026年2月28日 16:24

要了解js的执行机制,那么首先需要明白执行上下文、执行栈以及作用域和作用域链的概念。

执行上下文

执行上下文(Execution Context),缩写为EC。js代码在执行之前需要做一些准备,类比我们在上课之前需要在教室中准备好粉笔,黑板课桌等等。js代码在执行之前也需要做一些准备,给代码的执行创建一个环境 —— 执行上下文。

执行上下文的类型

  • 全局执行上下文:js代码在在执行前首先会创建一个全局执行上下文,并且一个程序只有一个。
  • 函数执行上下文:当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数执行上下文可以有多个。
  • eval函数执行上下文

现在了解了执行上下文,但是js代码在执行过程中怎么去找到不同的执行上下文呢?那么就需要一个存放这些不同执行上下文的地方 —— 执行栈。执行栈,顾名思义是一个栈的数据结构,有先入后出的特点。执行栈栈顶的执行上下文就是当前的执行上下文

执行栈.png

现在两个重要的概念都了解之后,我们学习一下执行上下文到底是什么,它运作的过程是什么样的?

执行上下文的结构

执行上下文结构.png

可以看到执行上下文中主要包含四个部分:

  1. 变量环境用来存储所有的var声明的变量以及函数声明;
  2. 词法环境用来存储let/const/class声明的变量和全局对象;
  3. this绑定对于全局执行上下文,如果是浏览器绑定的是window,如果是node.js指向的就是global,函数执行上下文中的this指向取决于函数的调用方法;
  4. 外部引用其实就是作用域链,全局执行上下文引用为null,函数执行上下文指向函数定义时的词法环境

了解了执行上下文的结构,我们来用一个小的代码片段说明一下执行上下文的创建和执行。

创建和执行步骤

  1. 创建全局执行上下文,并加入执行栈顶
  2. 分析:
    • 找到所有的非函数中的var声明,在变量环境中创建绑定
    • 找到所有的顶级函数声明,在变量环境中创建绑定
    • 找到顶级let、const、class声明,在词法环境中创建绑定
    • 块级作用域中的变量声明(let/const)创建新的词法环境放入其中,函数声明特殊,类似于let
  3. 变量名重复处理:let const class声明的变量名不能重复,他们与var function的名字也不能重复;若varfunction 名字重复,function声明的函数名优先
  4. 创建绑定:
    • 变量环境绑定:var初始化为undefined,函数初始化为函数对象,并且会把函数定义时的词法环境保存到函数对象中。
    • 词法环境绑定:let/const/class 创建但未初始化 —— 暂时性死区
  5. 执行语句
var a = 10
function foo(){
  console.log(a)
  let a
}
foo()

以上面的代码块为例

  • 创建阶段,创建全局的执行上下文,在变量环境中存放a变量,并初始化为undefined,存放函数名为foo,初始化为函数对象,并且保存函数定义时的词法环境(全局执行上下文的词法环境)。
  • 执行阶段,变量a赋值为10,foo函数被调用,会创建一个foo函数的执行上下文压入栈顶,这个执行上下文的词法环境的outer会指向foo函数初始化时保存在体内的那个词法环境,也就是指向函数定义时的执行上下文的词法环境(全局执行上下文的词法环境)。
    • 函数foo中又继续上述的创建步骤,在词法环境中存放变量a,未初始化。
    • 继续执行,console.log(a),当前的执行上下文就是foo函数的执行上下文,所以会在foo函数的执行上下文中去查找变量a,找到为未初始化的状态,所以最终会报错。

执行过程.png

到这里就说完了这个简单代码块的执行上下文的创建和执行,那么会不会有一个疑问,我在上面的描述中着重强调函数执行上下文的词法环境的一个指向,但是似乎也没有起到什么作用。

那么如果把函数foo中的let声明去掉我们就可以看出这个指向的用处了!如果去掉的话,函数执行上下文中就找不到这个变量a,那么就会沿着outer指向找到父级的执行上下文查看其中是否有变量a,最后会输出10。

这也就引出了作用域链

作用域

那么在介绍作用域链之前,我们先了解什么是作用域

作用域是解析(查找)变量名的一个集合,规定了变量和函数的可访问范围,也就是它定义了在哪里可以访问什么变量。作用域就类比规则,当前执行上下文的词法环境就类比实现规则的数据结构。

作用域的类型

  • 全局作用域:在代码任何地方都可访问的作用域,对应全局执行上下文
  • 函数作用域:函数内部创建的作用域,只在函数内部可访问。
  • 块级作用域:ES6 引入 letconst后新增的作用域,由 {}代码块创建。

我们再举一个例子来说明

function foo(){
    console.log(a)
}

funtion bar(){
    var a = 3
    foo()
}
var a = 2
bar()

以上的代码最终会输出2。

  • 创建阶段,将函数foo、函数bar以及变量a存放在全局执行上下文的变量环境中,并且初始化。
  • 执行阶段,首先给全局执行上下文中的a赋值2,遇到bar(),创建一个新的bar函数执行上下文,将该执行上下文的outer指向全局执行上下文的词法环境,然后运行函数内部的代码。
    • 创建阶段,函数内部的变量a存放在bar函数执行上下文的词法环境中。
    • 执行阶段,变量a赋值为3,调用foo函数。这时候会继续创建一个foo函数的执行上下文,并且它的outer指向也是全局执行上下文的词法环境,然后foo函数中就一句代码,输出a,就会先去当前执行上下文中查找有没有变量a,发现没有;那么!就会沿着outer指向继续往父级查找到全局执行上下文,发现有变量a,值为2。

作用域链体现.png

这就是作用域链!经过以上的分析,我们也发现函数的作用域是函数定义时的作用域决定的,和函数调用时的作用域没有关系,否则输出就应该是3。

现在对于函数作用域有了一定的了解后,我们继续看块级作用域。

遇到块级作用域,也有以下几个步骤:

  1. 创建新的记录环境(词法环境),连接在原来记录之前
  2. 分析:
    • 所有的顶级函数声明
    • let/const声明
  3. 名字重复处理
  4. 创建绑定:
    • 登记function,初始化为函数对象
    • 登记let const,未初始化
  5. 执行语句

细心的同学肯定已经发现,遇到块级作用域{},我们的处理方式其实之前类似,但是不会创建新的执行上下文了,而是改成了创建新的一个记录指向原来的记录。

用以下代码块来说明:

let inIf = 'out of statement'

if(true){
    let inIf = 'in of statement'
    console.log(inIf)
}

console.log(inIf)

最后会先输出in of statement,再输出 out of statement

块级作用域.png 如上图,执行完块级作用域后,这个记录就会销毁,然后把原先的记录重新接回。

那么我们再看下面这个例子,体会闭包的作用

var liList = []

for(var i = 0; i < 5; i++){
    liList[i] = function(){
        console.log(i)
     }
}

liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()

以上代码我们执行会发现,五个函数调用后输出的结果都是5。这是因为var没有块级作用域,会直接把变量i存放在全局执行上下文中,后面块级作用域中定义的所有函数的environment属性都指向全局执行上下文的词法环境,所以循环结束 i为5,每个函数调用创建的新的函数执行上下文中的outer都指向全局执行上下文,也会去全局中找i输出都为5。

将上述代码改为:

var liList = []

for(let i = 0; i < 5; i++){
    liList[i] = function(){
        console.log(i)
     }
}

liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()

就可以利用闭包,让调用输出的i不再是共享的值。因为let声明创建了块级作用域,每次循环都会创建一个新的词法环境存储变量i,定义的五个函数的enviroment属性也会指向不同的块级作用域。最后调用沿着作用域链找到的也是不同的i值。

闭包

这个例子清晰地展示了闭包的核心机制:函数能够记住并访问其定义时所处的词法作用域,即使该函数在其词法作用域之外被调用。在上面的例子中,每个函数都通过闭包"记住"了定义时所在的块级作用域(及其中的 i值)。

闭包的主要用途

  • 数据封装与私有变量
  • 函数工厂与柯里化
  • 事件处理与异步编程

闭包的注意事项

  • 内存泄漏风险
  • 性能考虑

脑筋急转弯(Python/Java/C++/C/Go/JS/Rust)

作者 endlesscheng
2026年2月24日 15:21

例如 $n=321$,其中最大的数字是 $3$。这个 $3$ 至少要拆分成 $3$ 个 $1$,即 $321=1__ + 1__ + 1__$。对于 $n$ 中的其余数字 $d$,可以拆分成 $d$ 个 $1$ 和 $3-d$ 个 $0$,即 $2=1+1+0$ 和 $1=1+0+0$,填到对应的位置上,得到 $321 = 111 + 110 + 100$。

一般地,设 $m$ 为 $n$ 中的最大数字,那么答案为 $m$。构造方案为:设 $n$ 的第 $i$ 个数字为 $n_i$,那么拆分出的这 $m$ 个数的第 $i$ 位上,有 $n_i$ 个 $1$ 和 $m-n_i$ 个 $0$(填入顺序随意)。

###py

class Solution:
    def minPartitions(self, n: str) -> int:
        return int(max(n))

###java

class Solution {
    public int minPartitions(String n) {
        int mx = 0;
        for (char ch : n.toCharArray()) {
            mx = Math.max(mx, ch);
        }
        return mx - '0';
    }
}

###cpp

class Solution {
public:
    int minPartitions(string n) {
        return ranges::max(n) - '0';
    }
};

###c

#define MAX(a, b) ((b) > (a) ? (b) : (a))

int minPartitions(char* n) {
    char mx = 0;
    for (int i = 0; n[i]; i++) {
        mx = MAX(mx, n[i]);
    }
    return mx - '0';
}

###go

func minPartitions(n string) int {
ans := rune(0)
for _, ch := range n {
ans = max(ans, ch)
}
return int(ans - '0')
}

###js

var minPartitions = function(n) {
    return Number(_.max(n));
};

###rust

impl Solution {
    pub fn min_partitions(n: String) -> i32 {
        (n.as_bytes().iter().max().unwrap() - b'0') as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(|n|)$,其中 $|n|$ 表示 $n$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

专题训练

见下面贪心与思维题单的「§5.2 脑筋急转弯」。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

c++/python3 均一行代码 本质:找最大的数字

作者 HanXin_HanXin
2021年4月15日 18:10

思路和心得:

1.因为分解时,长度是不限的

2.好比有一片连绵的山。每次最多消掉一层,消掉时宽度不限

最少的次数取决于peak的高度

class Solution:
    def minPartitions(self, n: str) -> int:
        ###### 本质: 找最大的数字
        return int(max(n))
class Solution 
{
public:
    int minPartitions(string n) 
    {
        ////////// 本质:找最大的数字
        return *max_element(n.begin(), n.end()) - '0';
    }
};

十_二进制

作者 zhu-freshzhu
2020年12月13日 16:28

解题思路

把每一个数字分解成若干个1,竖着将所有分解的1排列起来,以最下方的数字作为基准,上方空白的地方全部补零。
即:行数即为最小数目。也即,字符串中最大的数字就是最少数目。
    接下来的任务就是找到字符串中最大的数字是多少,可在遍历数组时利用flag记录较大数字的值。

例如:                  3 2
    可分解成=>          1 1   ····第一行
                        1 1   ····第二行
                        1 0   ····第三行
上面的0和1可随意排列组合。

代码

###cpp

class Solution {
public:
    int minPartitions(string n) {
        int flag=n[0]-'0';
        for(int i=0;i<n.length();i++){
            if(flag<(n[i]-'0'))
            flag=n[i]-'0';
        }
        return flag;
    }
};
❌
❌