阅读视图

发现新文章,点击刷新页面。

tee Cheatsheet

Basic Syntax

Core tee command forms.

Command Description
`command tee file.txt`
`command tee -a file.txt`
`command tee file1.txt file2.txt`
`command tee`
`command tee /tmp/out.log >/dev/null`

Common Options

Frequently used flags for tee.

Option Description
-a, --append Append to files instead of overwriting
-i, --ignore-interrupts Ignore interrupt signals
--help Show help text
--version Show version information

Logging Command Output

Capture output while still seeing it live.

Command Description
`ping -c 4 linuxize.com tee ping.log`
`journalctl -u nginx -n 50 tee nginx.log`
`ls -la tee listing.txt`
`df -h tee disk-usage.txt`
`free -h tee memory.txt`

Append Mode

Keep history in log files with -a.

Command Description
`date tee -a run.log`
`echo “deploy started” tee -a deploy.log`
`./backup.sh 2>&1 tee -a backup.log`
`tail -n 20 app.log tee -a diagnostics.log`
`curl -I https://linuxize.com tee -a headers.log`

Pipelines and Filters

Combine tee with text-processing commands.

Command Description
`cat app.log tee copy.log
`ps aux tee processes.txt
`sort users.txt tee sorted-users.txt
`dmesg tee dmesg.txt
`find /etc -maxdepth 1 -type f tee etc-files.txt

Privileged Writes

Write to root-owned files safely.

Command Description
`echo “127.0.0.1 app.local” sudo tee -a /etc/hosts`
`printf “key=value\n” sudo tee /etc/myapp.conf >/dev/null`
`cat config.conf sudo tee /etc/myapp/config.conf >/dev/null`
`echo “net.ipv4.ip_forward=1” sudo tee -a /etc/sysctl.conf`
`sudo sysctl -p tee sysctl-apply.log`

Troubleshooting

Quick checks for common tee issues.

Issue Check
Permission denied Use sudo tee for root-owned targets instead of sudo echo ... > file
File content replaced unexpectedly Use -a when you need append mode
No output on terminal Remove >/dev/null if you want to see output
Missing errors in logs Redirect stderr too: `2>&1
Command hangs in pipeline Check whether the upstream command runs continuously and needs manual stop

Related Guides

Use these guides for deeper command coverage and workflow patterns.

Guide Description
tee Command in Linux Full tee command tutorial
grep Command in Linux Filter matching lines
sort Command in Linux Sort text output
tail Command in Linux Inspect and follow recent lines
head Command in Linux Show first lines quickly
journalctl Command in Linux Query and filter systemd logs
Bash Append to File Append redirection patterns

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

做前端这么多年,最让我心疼的,就是那些拼尽全力学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 加速实时更新

原文: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 更容易「善后」的新能力

原文: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节点]原理解析与实际应用

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

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

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

本系列来自上海交通大学硕士,华为高级算法工程师 靳宇栋《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

前言

想在自己的应用中接入 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 专为中文优化

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

我们是袋鼠云数栈 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 的完整技术栈

引言

在当今前端开发的快节奏世界中,开发者们不再满足于“能用”的界面,而是追求高效、美观、可维护且体验流畅的 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! 🚀

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

如果一个十进制数字不含任何前导零,且每一位上的数字不是 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执行机制、作用域及作用域链

要了解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)

例如 $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 均一行代码 本质:找最大的数字

思路和心得:

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';
    }
};

十_二进制

解题思路

把每一个数字分解成若干个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;
    }
};

为什么现在不推荐使用 React.FC 了?

在 React + TypeScript 项目中,React.FC(即 React.FunctionComponent 的别名)曾经是定义函数组件的常见方式,但如今社区普遍推荐避免使用它,转而直接注解组件的 props 类型。下面我一步步解释原因和演变过程。

历史背景和早期问题

早期(React 17 及更早版本结合旧 TypeScript 时),React.FC 被广泛使用,因为它提供了一种简便的类型注解方式,能自动推断一些静态属性如 displayNamepropTypesdefaultProps。然而,它存在几个显著缺点:

  • 隐式 children propReact.FC 会自动将 children 添加到 props 类型中,即使组件不需要它。这会导致类型不准确,例如如果你定义一个不接受 children 的组件,用户仍能传入 children 而不会报错。
  • 泛型支持不佳:使用 React.FC 时,泛型组件的类型定义变得复杂且不易读。
  • defaultProps 问题:与 defaultProps 结合时,可能出现类型不兼容或推断错误。
  • 返回类型限制React.FC 强制返回 React.ReactElement,不允许直接返回 nullstringnumber 或其他类型,这在某些场景下不灵活。
  • 函数类型应用困难React.FC 本质上是函数类型,难以直接应用于命名函数,导致代码更冗长。

这些问题导致社区(如 Create React App 在 2020 年移除对 React.FC 的默认使用)开始转向其他方式。 许多知名开发者、TypeScript 贡献者和框架(如 Backstage)也公开不推荐它。

最近变化(React 18 和 TypeScript 5.1 后)

从 React 18(2022 年发布)开始,React.FC 的许多问题被修复:

  • 移除了隐式 children prop,现在只有显式定义时才会存在。
  • 支持更多返回类型,不再严格限制为 React.ReactElement

TypeScript 5.1(2023 年)进一步优化了这些,使得 React.FC 在技术上“可用”且不再“危险”。如果你已经在代码中使用它,也没有必要急于移除。

为什么现在还是不推荐使用?

尽管修复了早期缺陷,社区共识(包括 2025 年的最新讨论)仍建议避免 React.FC,因为它不再是最佳实践。理由包括:

  • 更简单的语法和更少 boilerplate:直接注解 props 更简洁、直观,符合纯 TypeScript 风格,而 React.FC 引入了不必要的框架特定类型。
  • 更好的灵活性:支持泛型、复合组件和“组件作为命名空间”的模式,而不需额外处理。
  • 学习曲线和可读性:新手可能觉得 React.FC 容易上手,但长期看,直接类型定义更易维护和阅读。
  • 现代工具支持:像 ESLint 规则和 codemod 可以自动禁止或替换 React.FC
  • 无额外价值React.FC 提供的自动推断(如静态属性)在现代 TypeScript 中已无需它就能实现。

总体上,使用 React.FC 不会出错,但它被视为过时,类似于一些旧的 React 模式。 如果你的项目是新启动的,或在重构时,建议切换到更现代的写法。

推荐的替代方式

直接定义函数组件,并注解 props 类型。示例:

// 旧方式(不推荐)
const MyComponent: React.FC<{ name: string }> = ({ name }) => <div>Hello, {name}</div>;

// 新方式(推荐)
function MyComponent({ name }: { name: string }) {
  return <div>Hello, {name}</div>;
}

// 或箭头函数
const MyComponent = ({ name }: { name: string }) => <div>Hello, {name}</div>;

// 如果需要 children 或其他复杂类型
type Props = {
  name: string;
  children?: React.ReactNode; // 显式添加,如果需要
};
function MyComponent({ name, children }: Props) {
  return <div>{name}{children}</div>;
}

这种方式更贴近 JavaScript 原生语法,同时充分利用 TypeScript 的类型推断。

深入理解 Vue 依赖收集:从源码角度拆解响应式的核心

在 Vue 的响应式系统中,“依赖收集” 是贯穿整个数据驱动视图的核心环节。很多开发者日常使用datacomputedwatch时只知其然,却不知其所以然 —— 为什么修改数据视图会自动更新?为什么computed能精准缓存?这一切的背后,都是依赖收集机制在起作用。

本文将从 Vue 2 的源码出发,层层拆解依赖收集的完整流程,带你从 “使用层” 走向 “原理层”,真正理解 Vue 响应式的底层逻辑。

一、先搞懂:什么是 “依赖”?

在开始源码分析前,我们先明确核心概念:

  • 依赖:本质上是 “使用了某个响应式数据的执行函数”,比如渲染组件的render函数、computed的计算函数、watch的回调函数。
  • 依赖收集:在响应式数据被读取时,记录下 “哪些函数依赖了这个数据”;当数据被修改时,找到这些记录的函数并执行,最终实现 “数据变 → 视图更”。

简单来说,依赖收集的核心目标是:建立 “响应式数据” 与 “使用数据的函数” 之间的映射关系

二、核心角色:依赖收集的 3 个关键模块

Vue 2 的依赖收集主要依赖三个核心模块,我们先认识它们:

表格

模块 作用 核心源码位置
Observer 将普通对象 / 数组转为响应式(给属性添加 get/set) src/core/observer/index.js
Dep 依赖管理器:存储某个响应式数据的所有依赖 src/core/observer/dep.js
Watcher 依赖的载体:封装需要执行的函数(如 render、computed) src/core/observer/watcher.js

三者的关系可以总结为:

Observer 给数据加 get/set 钩子 → 读取数据时触发 get,通过Dep收集Watcher → 修改数据时触发 set,通过Dep通知所有Watcher执行。

三、源码拆解:依赖收集的完整流程

3.1 第一步:响应式数据的初始化(Observer)

首先,Vue 会通过Observer类将data中的数据转为响应式,核心是给每个属性定义getter/setter

核心源码(简化版):

javascript

运行

// src/core/observer/index.js
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // 给对象/数组本身创建Dep
    def(value, '__ob__', this) // 给数据添加__ob__属性,标记为响应式
    
    if (Array.isArray(value)) {
      // 处理数组的响应式(重写push/pop等方法)
      this.observeArray(value)
    } else {
      // 处理对象的响应式:遍历属性并定义get/set
      this.walk(value)
    }
  }

  // 遍历对象属性,定义响应式
  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  // 遍历数组,给每个元素做响应式处理
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}

// 核心:给单个属性定义get/set
export function defineReactive(
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  // 每个响应式属性都有一个专属的Dep实例
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // 保留原有的get/set
  const getter = property && property.get
  const setter = property && property.set

  // 递归处理子属性,保证深层数据也是响应式
  let childOb = !shallow && observe(val)

  // 定义新的getter/setter
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    // 读取属性时触发:依赖收集的入口
    get: function reactiveGetter() {
      const value = getter ? getter.call(obj) : val
      
      // 关键:如果当前有活跃的Watcher,就收集依赖
      if (Dep.target) {
        dep.depend() // 1. 让Dep记录当前Watcher
        if (childOb) {
          // 2. 给对象/数组本身也收集依赖(处理数组/对象整体变更)
          childOb.dep.depend()
          if (Array.isArray(value)) {
            // 3. 数组特殊处理:遍历子元素收集依赖
            dependArray(value)
          }
        }
      }
      return value
    },
    // 修改属性时触发:通知依赖更新
    set: function reactiveSetter(newVal) {
      const value = getter ? getter.call(obj) : val
      // 新旧值相同则不处理
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      // 新值也要做响应式处理
      childOb = !shallow && observe(newVal)
      // 关键:通知所有依赖更新
      dep.notify()
    }
  })
}

核心要点

  • 每个响应式属性都会创建一个Dep实例,专属管理该属性的依赖;
  • getter中触发依赖收集,setter中触发依赖更新;
  • 不仅处理单个属性,还会递归处理子对象 / 数组,保证深层响应式。

3.2 第二步:依赖管理器(Dep)

Dep是依赖的 “容器”,核心作用是存储和管理某个数据的所有Watcher,提供depend(收集)和notify(通知)两个核心方法。

核心源码(简化版):

javascript

运行

// src/core/observer/dep.js
export default class Dep {
  static target: ?Watcher; // 静态属性,存储当前活跃的Watcher
  id: number; // 唯一标识
  subs: Array<Watcher>; // 存储依赖的Watcher数组

  constructor() {
    this.id = uid++
    this.subs = []
  }

  // 添加一个Watcher到依赖列表
  addSub(sub: Watcher) {
    this.subs.push(sub)
  }

  // 移除一个Watcher
  removeSub(sub: Watcher) {
    remove(this.subs, sub)
  }

  // 核心:收集依赖(让Dep和Watcher互相记录)
  depend() {
    if (Dep.target) {
      // 调用当前Watcher的addDep方法,双向绑定
      Dep.target.addDep(this)
    }
  }

  // 核心:通知所有Watcher更新
  notify() {
    // 复制一份依赖列表,避免更新过程中列表变化
    const subs = this.subs.slice()
    for (let i = 0, l = subs.length; i < l; i++) {
      // 调用Watcher的update方法
      subs[i].update()
    }
  }
}

// 全局唯一的Dep.target栈(处理嵌套Watcher,比如computed嵌套)
Dep.target = null
const targetStack = []

// 入栈:设置当前活跃的Watcher
export function pushTarget(target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

// 出栈:恢复上一个Watcher
export function popTarget() {
  targetStack.pop()
  Dep.target = targetStack[targetStack.length - 1]
}

核心要点

  • Dep.target是全局唯一的,始终指向 “当前正在执行的 Watcher”;
  • depend()方法不是直接添加 Watcher,而是调用Watcher.addDep(),实现 Dep 和 Watcher 的双向记录(避免重复收集);
  • 用栈结构targetStack处理嵌套场景(比如组件嵌套、computed 嵌套)。

3.3 第三步:依赖载体(Watcher)

Watcher是 “依赖” 的具体载体,每个 Watcher 对应一个需要执行的函数(比如组件的render函数、computed的计算函数)。

核心源码(简化版):

javascript

运行

// src/core/observer/watcher.js
export default class Watcher {
  constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: ?Object,
    isRenderWatcher?: boolean
  ) {
    this.vm = vm
    if (isRenderWatcher) {
      vm._watcher = this
    }
    vm._watchers.push(this)
    
    // 处理配置项(比如lazy、deep、sync)
    if (options) {
      this.lazy = !!options.lazy // computed用
      this.deep = !!options.deep // 深度监听用
      this.sync = !!options.sync // 同步更新用
    } else {
      this.lazy = this.deep = this.sync = false
    }
    
    this.cb = cb // 更新回调
    this.id = uid++ // 唯一标识
    this.deps = [] // 存储当前Watcher依赖的Dep
    this.newDeps = [] // 临时存储新依赖(用于依赖清理)
    this.depIds = new Set() // 去重
    this.newDepIds = new Set()
    
    // 解析表达式/函数,得到最终要执行的函数
    this.getter = typeof expOrFn === 'function' ? expOrFn : parsePath(expOrFn)
    
    // 非lazy模式(比如render、watch)立即执行get,触发依赖收集
    this.value = this.lazy ? undefined : this.get()
  }

  // 核心:执行getter并收集依赖
  get() {
    // 1. 将当前Watcher入栈,设置为Dep.target
    pushTarget(this)
    let value
    const vm = this.vm
    try {
      // 2. 执行getter(比如render函数),触发数据的getter
      // 此时数据的getter会检测到Dep.target,从而收集当前Watcher
      value = this.getter.call(vm, vm)
    } catch (e) {
      // 错误处理
    } finally {
      // 3. 深度监听处理
      if (this.deep) {
        traverse(value)
      }
      // 4. 出栈,恢复Dep.target
      popTarget()
      // 5. 清理无用的依赖
      this.cleanupDeps()
    }
    return value
  }

  // 核心:添加Dep到Watcher(与Dep.depend()配合)
  addDep(dep: Dep) {
    const id = dep.id
    // 避免重复收集
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        // 让Dep也记录当前Watcher
        dep.addSub(this)
      }
    }
  }

  // 清理无用依赖(比如数据从视图中移除后,不再监听)
  cleanupDeps() {
    // 省略清理逻辑...
  }

  // 核心:响应式数据更新时,触发Watcher更新
  update() {
    if (this.lazy) {
      // computed:标记为脏值,下次访问时重新计算
      this.dirty = true
    } else if (this.sync) {
      // 同步更新:立即执行run
      this.run()
    } else {
      // 异步更新(Vue默认):加入队列,批量更新
      queueWatcher(this)
    }
  }

  // 执行getter并触发回调
  run() {
    const value = this.get()
    if (value !== this.value || this.deep) {
      const oldValue = this.value
      this.value = value
      // 执行回调(比如watch的回调函数)
      this.cb.call(this.vm, value, oldValue)
    }
  }

  // computed专用:计算并返回最新值
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }

  // 重新收集依赖
  depend() {
    let i = this.deps.length
    while (i--) {
      this.deps[i].depend()
    }
  }
}

核心要点

  • Watcher.get()是触发依赖收集的关键:先将自身设为Dep.target,再执行getter(比如render函数),此时render中用到的所有响应式数据都会触发getter,从而收集当前 Watcher;

  • 不同类型的 Watcher 有不同的更新策略:

    • 渲染 Watcher(render):异步更新,加入队列批量执行;
    • 计算 Watcher(computed):懒更新(lazy: true),只有访问时才重新计算;
    • 侦听 Watcher(watch):可配置同步 / 异步,支持深度监听。

3.4 第四步:完整流程梳理(以组件渲染为例)

结合上面的源码,我们用流程图梳理组件渲染时的依赖收集完整流程:

预览

查看代码

组件初始化

创建渲染Watcher

生成失败,请重试

graph TD
    A[组件初始化] --> B[创建渲染Watcher]
    B --> C[执行Watcher.get()]
    C --> D[pushTarget:设置Dep.target为当前Watcher]
    D --> E[执行render函数]
    E --> F[读取响应式数据,触发getter]
    F --> G[Dep.depend():收集依赖]
    G --> H[Watcher.addDep():双向绑定Dep和Watcher]
    H --> I[render执行完成]
    I --> J[popTarget:恢复Dep.target]
    J --> K[依赖收集完成:数据→Dep→Watcher映射建立]
    L[修改响应式数据] --> M[触发setter]
    M --> N[Dep.notify():通知所有Watcher]
    N --> O[Watcher.update():执行更新]
    O --> P[重新执行render,更新视图]

组件初始化

创建渲染Watcher

生成失败,请重试

豆包

你的 AI 助手,助力每日工作学习

四、特殊场景的依赖处理

4.1 数组的依赖收集

数组的响应式处理和对象不同(因为数组的索引无法被Object.defineProperty拦截),Vue 重写了pushpopsplice等 7 个数组方法,核心逻辑:

javascript

运行

// src/core/observer/array.js
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

// 重写的7个方法
const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // 保留原方法
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator(...args) {
    // 执行原方法
    const result = original.apply(this, args)
    // 获取数组的__ob__(Observer实例)
    const ob = this.__ob__
    // 处理新增元素(push/unshift/splice),转为响应式
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // 关键:通知依赖更新
    ob.dep.notify()
    return result
  })
})

核心:数组的依赖收集在数组本身的__ob__.dep中,修改数组时调用ob.dep.notify()触发更新。

4.2 computed 的依赖收集

computed的 Watcher 是 “懒 Watcher”(lazy: true),特点:

  1. 初始化时不立即执行get,只有首次访问时才触发;
  2. 依赖的数据更新时,只标记dirty: true,不立即重新计算;
  3. 下次访问computed属性时,才重新计算并缓存结果。

核心逻辑:

javascript

运行

// src/core/instance/state.js
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {
        // 只有脏值时才重新计算
        watcher.evaluate()
      }
      // 收集渲染Watcher到computed的依赖中
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

五、常见问题与面试考点

5.1 为什么 Vue 不能检测数组索引和长度的变化?

  • 数组索引的get/set虽然能被Object.defineProperty拦截,但考虑到性能成本(数组元素可能很多),Vue 放弃了这种方式;
  • 数组长度的set也无法被有效拦截,且修改长度的场景较少;
  • 解决方案:使用 Vue 提供的变异方法(push/splice 等)或Vue.set

5.2 为什么修改对象的新属性视图不更新?

  • 因为对象初始化时,只有已定义的属性被添加了get/set,新属性没有;
  • 解决方案:使用Vue.set(obj, key, value)this.$set,本质是给新属性添加get/set并触发依赖更新。

5.3 依赖收集为什么要双向记录(Dep→Watcher 和 Watcher→Dep)?

  • 避免重复收集:通过depIdsnewDepIds去重;
  • 方便依赖清理:组件销毁时,Watcher 可以遍历自己的deps,从 Dep 中移除自身;
  • 支持依赖更新:Watcher 可以通过deps重新收集依赖(比如computeddepend方法)。

六、总结

Vue 的依赖收集机制是响应式系统的灵魂,核心可以总结为 3 点:

  1. 核心链路Observer给数据加get/set → 读取数据时Dep收集Watcher → 修改数据时Dep通知Watcher执行 → 视图更新;
  2. 核心角色Observer(响应式标记)、Dep(依赖容器)、Watcher(依赖载体)三者协同工作;
  3. 性能优化:通过懒更新(computed)、异步队列(渲染 Watcher)、依赖清理等方式,保证响应式的高效性。

理解依赖收集,不仅能帮你解决日常开发中的响应式问题,更能让你从底层理解 Vue 的设计思想。希望本文能让你对 Vue 的响应式系统有更深入的认识~

深入浅出 React 闭包陷阱:从现象到原理

深入浅出 React 闭包陷阱:从现象到原理

前言

React Hooks 的推出让函数组件焕发新生,我们可以用更简洁的代码实现状态和副作用。然而,Hooks 也带来了一些“坑”,其中 闭包陷阱 是初学者乃至有经验的开发者都容易遇到的问题。本文将从 JavaScript 闭包的基础出发,结合实际的 React 代码,一步步剖析闭包陷阱的成因、表现以及多种解决方案,帮助你彻底理解并避免它。

1. 什么是闭包?

在讨论 React 之前,我们必须先理解 JavaScript 中的闭包。闭包是指一个函数能够记住并访问它的词法作用域,即使该函数在其词法作用域之外执行。简单来说,闭包让你可以在一个内层函数中访问到外层函数的变量。

function outer() {
  let message = "Hello";
  function inner() {
    console.log(message); // inner 可以访问 outer 的变量
  }
  return inner;
}

const fn = outer();
fn(); // 输出 "Hello" —— 闭包使得 message 仍然可访问

闭包的形成需要两个条件:函数嵌套,且内部函数引用了外部函数的变量。当内部函数被返回或在其他地方被调用时,它依然持有对外部作用域的引用,这就是闭包。

2. React 函数组件中的闭包

React 函数组件每次渲染都会执行整个函数体,每次执行都会创建全新的局部变量和嵌套函数(如事件处理、useEffect 回调等)。这些嵌套函数会捕获当前渲染中的 props 和 state,形成闭包。

考虑一个简单的计数器组件:

function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  return <button onClick={handleClick}>{count}</button>;
}

每次渲染,handleClick 函数都是新创建的,它捕获的是本次渲染的 count 值。当用户点击按钮时,handleClick 中使用的 count点击发生时所处渲染的那一时刻的值,而不是最新的 state。这正是 React 正常工作的方式,也是每次渲染拥有独立 props 和 state 的体现。

3. 什么是闭包陷阱?

闭包陷阱(Closure Trap)通常指:在 useEffect、useCallback 等 Hook 中,由于依赖数组写得不正确,导致回调函数中捕获的是旧渲染中的状态值,从而引发 Bug

最常见的情景是在 useEffect 中启动一个定时器,并且依赖数组为空 [],期望定时器只运行一次,但定时器回调内部使用了外部的 state 或 props。由于空依赖的 Effect 只执行一次,回调函数捕获的是首次渲染时的值,后续更新后定时器依然使用旧值,导致“过期闭包”。

4. 代码演示:一个典型的闭包陷阱

来看一段示例代码:

import { useState, useEffect } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  // 打印此时的count值
  console.log("----------count:",count)

  // ❌ 闭包陷阱版本
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖数组

  return (
    <>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        count + 1
      </button>
    </>
  );
}

现象:点击按钮增加 count,页面显示的数字会更新,但控制台每隔一秒打印的 count 始终是 0,永远不会变化。

为什么?

  • 首次渲染时,count = 0useEffect 运行,创建定时器,定时器回调通过闭包捕获了本次渲染的 count 值(0)。
  • 点击按钮,setCount 触发重新渲染,count 变为 1。但由于依赖数组为空,useEffect 不会重新执行,定时器依然是旧的,其回调仍然持有旧的 count = 0
  • 于是每次定时器执行,都打印 0。

这就是典型的闭包陷阱:异步操作(定时器)引用了过时的状态。

效果图,可以看到尽管count已经加一,当时此时定时器打印的值仍为0

屏幕录制 2026-03-01 160044.gif

5. 原因深度剖析

要彻底理解这个问题,需要明白两件事:

5.1 每次渲染都有独立的“快照”

React 函数组件每次渲染就像是一次函数调用,参数是当前的 props 和 state。在某个特定渲染中,所有的变量(count、setCount 等)都是该渲染的常量。定时器回调是在未来某个时刻执行的,但它定义时的作用域是本次渲染,所以它捕获的是本次渲染的值。

5.2 Effect 的清理机制

useEffect 的返回函数(清理函数)会在组件卸载前执行,也会在每次 Effect 重新执行前执行(清理上一次的 Effect)。当依赖数组变化时,React 会先运行上一次的清理函数,再运行新的 Effect。

如果我们在依赖数组中包含 count

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // ✅ 依赖 count

那么每次 count 变化时,都会:

  1. 清理上一次的定时器。
  2. 重新创建新的定时器,新回调捕获最新的 count
  3. 控制台每次打印的值都是最新的。

这解决了闭包陷阱,但也意味着定时器会被频繁重置,可能不是我们想要的效果(比如我们想要一个持续运行的定时器,但能读取最新值)。

效果图,可以看到此时的count加一,定时器打印出的count也随之增加

屏幕录制 2026-03-01 160448.gif

6. 解决方案

6.1 在依赖数组中包含所有外部依赖

最简单直接的方法:将 Effect 中用到的所有响应式值(state、props)都放入依赖数组。这样每次值变化,Effect 都会重新执行,确保闭包总是新鲜的。

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

优点:简单、符合直觉。
缺点:如果依赖变化频繁,可能导致 Effect 频繁创建销毁,影响性能;某些场景(如定时器)可能并不希望被频繁重置。

6.2 使用 ref 保存最新值

useRef 返回一个可变对象,它的 current 属性在组件整个生命周期内保持不变,修改它不会触发重新渲染。我们可以利用 ref 来保存最新的状态,在异步回调中读取 ref.current。

const [count, setCount] = useState(0);
const countRef = useRef(count);

// 每次渲染后更新 ref 的值
useEffect(() => {
  countRef.current = count;
}, [count]);

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Current count:', countRef.current); // ✅ 总是最新值
  }, 1000);
  return () => clearInterval(timer);
}, []); // 依赖数组为空,定时器不会重置

原理:ref 是一个容器,我们可以手动保持它与 state 同步。由于定时器回调通过闭包捕获的是 countRef 这个对象(而不是它的值),而对象引用不变,但 current 属性可以随时更新,因此总能访问到最新的 count。

优点:定时器只创建一次,不会因 count 变化而重启。
缺点:需要手动同步 ref 与 state(可以用一个 useEffect 来做),代码稍显啰嗦。

6.3 使用 useReducer 或函数式更新

如果定时器逻辑只需要基于当前 state 计算新值(而不需要直接读取 state 用于其他目的),可以使用 setState 的函数式更新形式,但这通常适用于更新 state 的场景,而不是读取。

例如:

useEffect(() => {
  const timer = setInterval(() => {
    setCount(prevCount => prevCount + 1); // 基于前一个值更新
  }, 1000);
  return () => clearInterval(timer);
}, []);

这里我们不需要读取 count 的值,而是用函数式更新,因此没有闭包陷阱。但如果我们确实需要读取 count 做其他操作(比如打印),这种方法就不适用。

6.4 自定义 Hook 封装

对于常见场景,可以封装一个自定义 Hook 来简化 ref 方案。比如 useInterval

function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    if (delay !== null) {
      const id = setInterval(tick, delay);
      return () => clearInterval(id);
    }
  }, [delay]);
}

使用时:

useInterval(() => {
  console.log('Current count:', count);
}, 1000);

这个自定义 Hook 内部使用 ref 保存最新的回调,从而避免了闭包陷阱,且定时器不会因为依赖变化而重启(除非 delay 变化)。

7. useCallback 中的闭包陷阱

类似的问题也会出现在 useCallback 中。例如:

const handleClick = useCallback(() => {
  console.log(count); // 依赖 count
}, []); // 空依赖

handleClick 捕获了首次渲染的 count,后续无论 count 如何变化,handleClick 都不会更新,导致调用时总是旧值。

解决:在依赖数组中正确填写所有依赖。

const handleClick = useCallback(() => {
  console.log(count);
}, [count]);

或者使用 ref 方案:

const countRef = useRef(count);
useEffect(() => {
  countRef.current = count;
}, [count]);

const handleClick = useCallback(() => {
  console.log(countRef.current);
}, []); // 依赖为空,但 ref 总是最新

8. 总结

React 闭包陷阱本质是函数式组件每次渲染的独立性异步操作持久化引用旧渲染环境之间的矛盾。理解闭包和 React 渲染机制是避免陷阱的关键。

最佳实践建议

  1. 遵守 Hooks 规则:useEffect、useCallback 等 Hook 的依赖数组必须包含所有外部依赖(即该 Effect 或回调中使用的所有 props、state 以及由它们衍生而来的值)。ESLint 插件 eslint-plugin-react-hooks 会帮助你自动检查依赖,建议开启。

  2. 合理选择解决方案

    • 如果 Effect 需要响应变化且重置成本低,直接添加依赖即可。
    • 如果 Effect 需要持久运行且必须读取最新值,考虑 ref 方案或封装自定义 Hook。
    • 对于定时器、事件监听等场景,优先考虑自定义 Hook(如 useIntervaluseEventListener)来统一处理。
  3. 理解闭包:编写 React 代码时,时刻提醒自己:函数组件每次渲染都是一次独立的“快照”,异步回调捕获的是定义时的快照。

最后,强烈推荐使用 React 官方提供的 ESLint 规则,它可以捕获绝大多数遗漏依赖的情况,是避免闭包陷阱的第一道防线。

希望本文能帮助你彻底掌握 React 闭包陷阱,从此写出更健壮的代码!

表单最佳实践:从 v-model 到自定义表单组件(含校验)

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、v-model 到底是什么?——先把"语法糖"这三个字吃透

很多人写了好几年 Vue,天天用 v-model,但如果问一句:"v-model 的本质是什么?",不少人只能说出"双向绑定"四个字就卡住了。

一句话结论:v-model 是一颗语法糖,它帮你把"传值进去 + 监听变化抛出来"这两步合成了一步。

1.1 在原生元素上:v-model = :value + @input

先看最基础的用法:

<template>
  <input v-model="username" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

这段代码等价于:

<template>
  <input :value="username" @input="username = $event.target.value" />
</template>

<script setup>
import { ref } from 'vue'
const username = ref('')
</script>

看到了吗?Vue 帮你做了两件事:

  1. username 的值通过 :value 绑定到 input 上(数据 → 视图)
  2. 监听 input 的 input 事件,拿到用户输入的新值,赋回给 username(视图 → 数据)

这就是所谓的"双向绑定"——但本质上并不神秘,就是一个 prop 传入 + 一个事件抛出的简写。

踩坑提醒: 不同的原生表单元素,v-model 背后绑定的属性和事件是不一样的:

元素 绑定的属性 监听的事件
<input type="text"> value input
<input type="checkbox"> checked change
<select> value change
<textarea> value input

所以当你用原生 checkbox 配合 v-model 时,它走的是 checked + change,别和 text input 搞混。

1.2 一个常见的新手困惑:v-model 和 :value 能一起写吗?

不能。 写了 v-model 就不要再手动写 :value,因为 v-model 已经包含了 :value 的行为。如果你两个都写,Vue 会在控制台警告你冲突。

<!-- ❌ 错误写法 -->
<input v-model="username" :value="username" />

<!-- ✅ 二选一 -->
<input v-model="username" />
<!-- 或者 -->
<input :value="username" @input="username = $event.target.value" />

二、Vue 3 自定义组件的 v-model——规则变了,别用 Vue 2 的老习惯

如果说原生元素上的 v-model 是"开胃菜",那自定义组件上的 v-model 才是日常业务中真正高频使用的。而且 Vue 3 对 v-model 的机制做了重大改动,这里是很多从 Vue 2 迁移过来的同学最容易踩坑的地方。

2.1 Vue 2 vs Vue 3 的对比

特性 Vue 2 Vue 3
默认 prop 名 value modelValue
默认事件名 input update:modelValue
多个 v-model ❌ 不支持(要用 .sync ✅ 原生支持
.sync 修饰符 ❌ 移除了,用 v-model:xxx 替代

2.2 最基础的自定义组件 v-model

场景: 封装一个自定义输入框组件 MyInput

子组件 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="my-input"
    />
  </div>
</template>

<script setup>
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

defineEmits(['update:modelValue'])
</script>

父组件使用:

<template>
  <MyInput v-model="username" />
  <p>你输入的是:{{ username }}</p>
</template>

<script setup>
import { ref } from 'vue'
import MyInput from './MyInput.vue'

const username = ref('')
</script>

拆解: 父组件写 v-model="username",Vue 3 会自动展开为:

<MyInput :modelValue="username" @update:modelValue="username = $event" />

所以子组件需要做两件事:

  1. modelValue 这个 prop 接收值
  2. 变化时通过 $emit('update:modelValue', newValue) 把新值抛出去

踩坑提醒: 事件名必须是 update:modelValue,中间是冒号,不是横杠、不是驼峰拼接。很多人写成 updateModelValue 或者 update-model-value,都是错的。

2.3 多个 v-model——Vue 3 的杀手级特性

Vue 2 时代,一个组件只能有一个 v-model,如果要双向绑定多个值,得用 .sync 修饰符,写起来很割裂。Vue 3 直接支持多个 v-model,优雅多了。

场景: 一个用户信息组件,同时需要双向绑定姓名和年龄。

子组件 UserFields.vue

<template>
  <div class="user-fields">
    <label>
      姓名:
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      />
    </label>
    <label>
      年龄:
      <input
        type="number"
        :value="age"
        @input="$emit('update:age', Number($event.target.value))"
      />
    </label>
  </div>
</template>

<script setup>
defineProps({
  name: { type: String, default: '' },
  age: { type: Number, default: 0 }
})

defineEmits(['update:name', 'update:age'])
</script>

父组件使用:

<template>
  <UserFields v-model:name="form.name" v-model:age="form.age" />
  <p>姓名:{{ form.name }},年龄:{{ form.age }}</p>
</template>

<script setup>
import { reactive } from 'vue'
import UserFields from './UserFields.vue'

const form = reactive({
  name: '',
  age: 0
})
</script>

v-model:name="form.name" 展开后就是 :name="form.name" @update:name="form.name = $event"。规则和默认的 v-model 一样,只是把 modelValue 换成了你自己指定的 prop 名。

2.4 defineModel()——Vue 3.4+ 的终极简化

从 Vue 3.4 开始,defineModel() 正式转正(之前是实验性 API)。它让自定义组件的 v-model 写法大幅简化。

改造上面的 MyInput.vue

<template>
  <div class="my-input-wrapper">
    <input v-model="model" class="my-input" />
  </div>
</template>

<script setup>
const model = defineModel({ type: String, default: '' })
</script>

没有了手动 defineProps + defineEmits,也不用自己写 $emitdefineModel() 返回的是一个 ref,你直接用 v-model 绑定到原生 input 上就行,它会自动帮你处理和父组件之间的双向通信。

多个 v-model 也支持:

<script setup>
const name = defineModel('name', { type: String, default: '' })
const age = defineModel('age', { type: Number, default: 0 })
</script>

选型建议:

  • 如果你的项目已经是 Vue 3.4+,强烈推荐用 defineModel(),代码量少、可读性好、不容易出错。
  • 如果项目还在 Vue 3.3 及以下,老老实实用 defineProps + defineEmits 的经典写法。
  • 别在生产项目里用实验性 API,等转正了再上。

三、表单组件拆分的实战思路——什么时候该拆?怎么拆?

知道了 v-model 的原理,接下来聊聊实战中最常遇到的问题:表单越写越长,该怎么拆?

3.1 不拆的代价

先看一个典型的"不拆"写法——一个订单表单,所有字段怼在一个组件里:

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <!-- 基本信息 -->
    <el-form-item label="订单名称" prop="name">
      <el-input v-model="form.name" />
    </el-form-item>
    <el-form-item label="订单类型" prop="type">
      <el-select v-model="form.type">
        <el-option label="普通" value="normal" />
        <el-option label="加急" value="urgent" />
      </el-select>
    </el-form-item>
    <!-- 收货信息 -->
    <el-form-item label="收货人" prop="receiver">
      <el-input v-model="form.receiver" />
    </el-form-item>
    <el-form-item label="手机号" prop="phone">
      <el-input v-model="form.phone" />
    </el-form-item>
    <el-form-item label="地址" prop="address">
      <el-input v-model="form.address" />
    </el-form-item>
    <!-- 商品信息 -->
    <el-form-item label="商品名称" prop="product">
      <el-input v-model="form.product" />
    </el-form-item>
    <el-form-item label="数量" prop="quantity">
      <el-input-number v-model="form.quantity" :min="1" />
    </el-form-item>
    <el-form-item label="备注" prop="remark">
      <el-input v-model="form.remark" type="textarea" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

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

const formRef = ref()
const form = reactive({
  name: '', type: '', receiver: '', phone: '',
  address: '', product: '', quantity: 1, remark: ''
})
const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
  product: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
  quantity: [{ required: true, message: '请输入数量', trigger: 'change' }]
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form)
}
</script>

这还只是 8 个字段。现实业务中,一个表单二三十个字段是常态,加上联动逻辑、动态显隐、异步校验……一个文件轻松上千行。

问题在哪?

  • 改一个区块的字段,要在一大坨模板里翻半天
  • 校验规则和字段分离,对应关系全靠 prop 字符串"人肉匹配"
  • 无法复用——收货信息这一块在别的页面也要用,你只能复制粘贴

3.2 拆分原则:按业务区块拆,不是按字段拆

核心原则:一个子表单组件 = 一个业务含义的区块。

以上面的订单表单为例,天然可以拆成三块:

  1. 基本信息 —— OrderBasicInfo.vue
  2. 收货信息 —— ReceiverInfo.vue
  3. 商品信息 —— ProductInfo.vue

3.3 拆分后的代码实现

子组件 ReceiverInfo.vue(收货信息区块):

<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input :model-value="modelValue.receiver" @update:model-value="updateField('receiver', $event)" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input :model-value="modelValue.phone" @update:model-value="updateField('phone', $event)" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input :model-value="modelValue.address" @update:model-value="updateField('address', $event)" />
  </el-form-item>
</template>

<script setup>
const props = defineProps({
  modelValue: {
    type: Object,
    required: true
  }
})

const emit = defineEmits(['update:modelValue'])

const updateField = (field, value) => {
  emit('update:modelValue', { ...props.modelValue, [field]: value })
}
</script>

关键点解读:

  1. 子组件通过 modelValue 接收整个区块的数据对象(不是单个字段)
  2. 更新时,用展开运算符创建一个新对象{ ...props.modelValue, [field]: value }
  3. 整体抛出给父组件,让父组件拿到新值去更新

⚠️ 这里有一个非常重要的踩坑点:为什么不能直接修改 props?

你可能想:props.modelValue 是个对象,我直接 props.modelValue.receiver = '张三' 不行吗?

技术上可以,Vue 不会报错(对象是引用传递)。但这是一个非常坏的习惯。 原因:

  • 违反了 Vue 的"单向数据流"原则——数据应该从父组件流向子组件,子组件想改数据,应该通过事件通知父组件去改
  • 当组件层级变深、多个子组件共享同一份数据时,直接修改 props 会导致"数据在哪被改的"完全无法追踪
  • 在使用 Vue DevTools 调试时,直接改 props 不会触发事件记录,等于"偷偷改了但没人知道"

结论:永远通过 emit 通知父组件修改,哪怕多写几行代码。

父组件 OrderForm.vue(组装):

<template>
  <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
    <h3>基本信息</h3>
    <OrderBasicInfo v-model="basicInfo" />

    <h3>收货信息</h3>
    <ReceiverInfo v-model="receiverInfo" />

    <h3>商品信息</h3>
    <ProductInfo v-model="productInfo" />

    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, computed } from 'vue'
import OrderBasicInfo from './OrderBasicInfo.vue'
import ReceiverInfo from './ReceiverInfo.vue'
import ProductInfo from './ProductInfo.vue'

const formRef = ref()

const basicInfo = ref({ name: '', type: '' })
const receiverInfo = ref({ receiver: '', phone: '', address: '' })
const productInfo = ref({ product: '', quantity: 1, remark: '' })

// 组装完整表单数据(用于提交和校验)
const form = computed(() => ({
  ...basicInfo.value,
  ...receiverInfo.value,
  ...productInfo.value
}))

const rules = {
  name: [{ required: true, message: '请输入订单名称', trigger: 'blur' }],
  receiver: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
  ],
  // ...其他规则
}

const handleSubmit = async () => {
  await formRef.value.validate()
  console.log('提交数据:', form.value)
}
</script>

3.4 用 defineModel() 简化子组件(Vue 3.4+)

如果项目版本允许,子组件可以进一步简化:

<!-- ReceiverInfo.vue(Vue 3.4+ 简化版)-->
<template>
  <el-form-item label="收货人" prop="receiver">
    <el-input v-model="model.receiver" />
  </el-form-item>
  <el-form-item label="手机号" prop="phone">
    <el-input v-model="model.phone" />
  </el-form-item>
  <el-form-item label="地址" prop="address">
    <el-input v-model="model.address" />
  </el-form-item>
</template>

<script setup>
const model = defineModel({ type: Object, required: true })
</script>

注意: defineModel() 返回的 ref 本质上会对对象的属性修改进行追踪并自动触发 update:modelValue。但如果你需要更细粒度的控制(比如只在某些条件下才允许更新),还是用 defineProps + defineEmits 的显式写法更合适。

四、Element Plus 表单封装实战——来点真正的项目级经验

上面讲了拆分的基本思路,下面来看在 Element Plus 体系下,实际项目中常用的几个封装模式。

4.1 踩坑重灾区:el-form 的 prop 路径与嵌套对象

当表单数据是嵌套结构时,el-form-itemprop 需要写成路径形式,否则校验不生效。

<template>
  <el-form :model="form" :rules="rules" ref="formRef">
    <!-- ❌ 错误:prop 写成 "name",但数据在 form.basic.name -->
    <el-form-item label="姓名" prop="name">
      <el-input v-model="form.basic.name" />
    </el-form-item>

    <!-- ✅ 正确:prop 要写完整路径 -->
    <el-form-item label="姓名" prop="basic.name">
      <el-input v-model="form.basic.name" />
    </el-form-item>
  </el-form>
</template>

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

const form = reactive({
  basic: { name: '', age: 0 },
  contact: { phone: '', email: '' }
})

const rules = {
  // 规则的 key 也要用完整路径
  'basic.name': [{ required: true, message: '请输入姓名', trigger: 'blur' }],
  'basic.age': [{ required: true, message: '请输入年龄', trigger: 'blur' }]
}
</script>

踩坑总结:

  • prop 的值必须和 form 对象中的路径一一对应
  • rules 对象的 key 也必须用相同的路径
  • 如果 prop 和实际数据路径对不上,校验静默失败——不报错、不提示、就是不校验,非常难排查

4.2 封装一个通用的表单弹窗组件

这是项目中使用频率最高的模式之一——点击按钮弹出表单弹窗,填写后提交。

<!-- FormDialog.vue -->
<template>
  <el-dialog
    :model-value="visible"
    @update:model-value="$emit('update:visible', $event)"
    :title="title"
    width="600px"
    :close-on-click-modal="false"
    @closed="handleClosed"
  >
    <el-form
      ref="formRef"
      :model="formData"
      :rules="rules"
      label-width="100px"
      @submit.prevent
    >
      <slot :form="formData" />
    </el-form>
    <template #footer>
      <el-button @click="$emit('update:visible', false)">取消</el-button>
      <el-button type="primary" :loading="loading" @click="handleConfirm">
        确定
      </el-button>
    </template>
  </el-dialog>
</template>

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

const props = defineProps({
  visible: { type: Boolean, default: false },
  title: { type: String, default: '表单' },
  rules: { type: Object, default: () => ({}) },
  initialData: { type: Object, default: () => ({}) }
})

const emit = defineEmits(['update:visible', 'confirm'])

const formRef = ref()
const formData = ref({})
const loading = ref(false)

// 每次打开弹窗时,用 initialData 初始化表单
watch(() => props.visible, (val) => {
  if (val) {
    formData.value = JSON.parse(JSON.stringify(props.initialData))
  }
})

const handleConfirm = async () => {
  try {
    await formRef.value.validate()
    loading.value = true
    emit('confirm', { ...formData.value })
  } catch {
    // 校验未通过,不做处理
  } finally {
    loading.value = false
  }
}

const handleClosed = () => {
  formRef.value?.resetFields()
}
</script>

父组件使用:

<template>
  <el-button @click="dialogVisible = true">新增用户</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    title="新增用户"
    :rules="rules"
    :initial-data="defaultForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const defaultForm = { username: '', email: '' }

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  // 调用接口提交
  console.log('提交的数据:', formData)
  dialogVisible.value = false
}
</script>

这个封装的核心设计:

设计点 为什么这么做
initialData 深拷贝 避免编辑时直接修改原始数据,取消后数据被"污染"
@closedresetFields 关闭动画结束后再清理,避免用户看到表单闪烁
:close-on-click-modal="false" 防止用户填了一半误触遮罩关闭
@submit.prevent 防止表单内按回车触发页面刷新
通过 slot 传入表单项 表单内容由调用方决定,弹窗组件只管"壳"和"行为"

踩坑提醒: resetFields() 只会重置到 el-form 初始挂载时的值,不是清空为空字符串。所以如果你在弹窗打开后才给 formData 赋值,resetFields 可能重置到的是空对象而不是你期望的初始值。这就是为什么我们用 watchvisible 变为 true 时就立即赋值——确保表单挂载时就有正确的初始数据。

4.3 编辑与新增共用同一个弹窗

实际业务中,新增和编辑往往共用一个表单弹窗,只是初始数据不同。

<template>
  <el-button @click="handleAdd">新增</el-button>
  <el-button @click="handleEdit(mockData)">编辑</el-button>

  <FormDialog
    v-model:visible="dialogVisible"
    :title="isEdit ? '编辑用户' : '新增用户'"
    :rules="rules"
    :initial-data="currentForm"
    @confirm="handleConfirm"
  >
    <template #default="{ form }">
      <el-form-item label="用户名" prop="username">
        <el-input v-model="form.username" :disabled="isEdit" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
        <el-input v-model="form.email" />
      </el-form-item>
    </template>
  </FormDialog>
</template>

<script setup>
import { ref, computed } from 'vue'
import FormDialog from './FormDialog.vue'

const dialogVisible = ref(false)
const editingItem = ref(null)

const isEdit = computed(() => !!editingItem.value)

const defaultForm = { username: '', email: '' }
const currentForm = computed(() =>
  isEdit.value ? { ...editingItem.value } : { ...defaultForm }
)

const mockData = { id: 1, username: '张三', email: 'zhangsan@example.com' }

const handleAdd = () => {
  editingItem.value = null
  dialogVisible.value = true
}

const handleEdit = (item) => {
  editingItem.value = item
  dialogVisible.value = true
}

const rules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }
  ]
}

const handleConfirm = async (formData) => {
  if (isEdit.value) {
    console.log('更新数据:', { id: editingItem.value.id, ...formData })
  } else {
    console.log('新增数据:', formData)
  }
  dialogVisible.value = false
}
</script>

五、表单校验的那些事——从基础到自定义

5.1 Element Plus 校验的基本运作方式

Element Plus 的表单校验基于 async-validator 这个库。理解它的工作流程:

用户输入 → 触发 trigger 事件(blur/change)→ 根据 prop 查找对应的 rules → 执行校验 → 显示/隐藏错误提示

5.2 自定义校验器(validator)

内置规则(required、min、max、pattern 等)能覆盖大多数场景,但碰到复杂逻辑就得用自定义 validator。

const rules = {
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value.length < 8) {
          callback(new Error('密码至少8位'))
        } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
          callback(new Error('密码需包含大小写字母和数字'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ],
  confirmPassword: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    {
      validator: (rule, value, callback) => {
        if (value !== form.password) {
          callback(new Error('两次密码不一致'))
        } else {
          callback()
        }
      },
      trigger: 'blur'
    }
  ]
}

注意事项:

  • callback 必须调用,不管校验通过还是失败。忘记调用会导致校验"卡死"——按钮一直 loading,表单提交不了也不报错
  • 校验通过时调用 callback()(不传参数)
  • 校验失败时调用 callback(new Error('错误信息'))

5.3 异步校验(如:检查用户名是否已存在)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    {
      validator: async (rule, value, callback) => {
        try {
          const { data } = await checkUsernameApi(value)
          if (data.exists) {
            callback(new Error('用户名已被占用'))
          } else {
            callback()
          }
        } catch {
          callback(new Error('校验失败,请稍后再试'))
        }
      },
      trigger: 'blur'
    }
  ]
}

踩坑提醒: 异步校验如果不做防抖,用户每输入一个字符都会发请求。建议给异步校验加一个 debounce

import { debounce } from 'lodash-es'

const checkUsername = debounce(async (value, callback) => {
  try {
    const { data } = await checkUsernameApi(value)
    data.exists ? callback(new Error('用户名已被占用')) : callback()
  } catch {
    callback(new Error('校验失败,请稍后再试'))
  }
}, 500)

const rules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { validator: (rule, value, callback) => checkUsername(value, callback), trigger: 'blur' }
  ]
}

5.4 动态表单项的校验

动态增删的表单项(比如"添加更多联系人"),校验规则需要跟着走。

<template>
  <el-form :model="form" ref="formRef" label-width="100px">
    <div v-for="(contact, index) in form.contacts" :key="index" class="contact-row">
      <el-form-item
        :label="'联系人' + (index + 1)"
        :prop="'contacts.' + index + '.name'"
        :rules="[{ required: true, message: '请输入姓名', trigger: 'blur' }]"
      >
        <el-input v-model="contact.name" />
      </el-form-item>
      <el-form-item
        label="电话"
        :prop="'contacts.' + index + '.phone'"
        :rules="[
          { required: true, message: '请输入电话', trigger: 'blur' },
          { pattern: /^1[3-9]\d{9}$/, message: '格式不正确', trigger: 'blur' }
        ]"
      >
        <el-input v-model="contact.phone" />
      </el-form-item>
      <el-button @click="removeContact(index)" type="danger" text>删除</el-button>
    </div>
    <el-button @click="addContact" type="primary" plain>添加联系人</el-button>
    <el-form-item>
      <el-button type="primary" @click="handleSubmit">提交</el-button>
    </el-form-item>
  </el-form>
</template>

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

const formRef = ref()
const form = reactive({
  contacts: [{ name: '', phone: '' }]
})

const addContact = () => {
  form.contacts.push({ name: '', phone: '' })
}

const removeContact = (index) => {
  form.contacts.splice(index, 1)
}

const handleSubmit = async () => {
  try {
    await formRef.value.validate()
    console.log('提交数据:', form.contacts)
  } catch {
    console.log('校验未通过')
  }
}
</script>

踩坑要点:

  • :prop 必须是动态的,格式为 '数组名.' + index + '.字段名'
  • 校验规则可以直接写在 el-form-item:rules 上,不用全放在 el-form:rules
  • v-for 一定要绑定 :key,且最好不要用 index 作为 key(删除中间项时会导致校验状态错乱)。推荐给每个 contact 加一个唯一 id

改进后的做法:

import { nanoid } from 'nanoid'

const addContact = () => {
  form.contacts.push({ id: nanoid(), name: '', phone: '' })
}
<div v-for="(contact, index) in form.contacts" :key="contact.id">

六、常见踩坑汇总

写了这么多,最后把文中提到的和额外的高频坑点汇总一下,方便速查:

# 踩坑点 现象 正确做法
1 v-model 和 :value 同时写 控制台警告,行为异常 二选一,不要混用
2 Vue 3 事件名写错 子组件修改不生效 必须是 update:modelValue
3 子组件直接修改 props 对象 短期没问题,长期数据流混乱 通过 emit 通知父组件修改
4 prop 路径和数据路径不匹配 校验静默失败 prop 值必须对应 form 对象的完整路径
5 validator 忘记调用 callback 表单提交"卡死" 无论通过还是失败都要调用 callback
6 resetFields 重置到空而非初始值 编辑弹窗关闭后数据异常 确保表单挂载时就有正确初始数据
7 动态表单 v-for 用 index 做 key 删除项后校验状态错乱 用唯一 id 作为 key
8 异步校验没做防抖 疯狂发请求 用 debounce 包装异步校验
9 弹窗表单 close-on-click-modal 填了一半误触关闭 设为 false
10 表单内按回车刷新页面 原生 form 默认提交行为 @submit.prevent

七、总结

回顾整篇文章的知识脉络:

v-model 本质(语法糖)
    ├─ 原生元素::value + @input
    └─ 自定义组件
         ├─ Vue 3 经典写法:defineProps + defineEmits
         ├─ Vue 3.4+ 简化:defineModel()
         └─ 多个 v-model:v-model:xxx
              │
              ▼
表单组件拆分
    ├─ 按业务区块拆分
    ├─ 子组件通过 v-model 和父组件通信
    └─ 永远不要直接修改 props
              │
              ▼
Element Plus 表单封装
    ├─ 嵌套对象的 prop 路径
    ├─ 通用表单弹窗组件
    └─ 新增/编辑共用弹窗
              │
              ▼
表单校验
    ├─ 自定义 validator
    ├─ 异步校验 + 防抖
    └─ 动态表单项校验

表单是前端日常工作中占比最大的 UI 模式之一。把 v-model 的本质搞清楚,把组件拆分的边界想明白,把 Element Plus 的校验机制摸透——这三件事做到了,你在日常表单开发中就能做到写得快、改得动、不踩坑


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

React性能优化:深入理解useMemo和useCallback

React性能优化:深入理解useMemo和useCallback

前言

在React函数组件中,每次状态更新都会导致整个组件函数重新执行。如果组件内部有复杂的计算或者传递了回调函数给子组件,可能会引发不必要的性能开销。React为我们提供了两个重要的Hook:useMemouseCallback,用于缓存计算结果和函数引用,从而优化组件性能。本文将从浅入深,结合实际代码,带你彻底理解这两个Hook的使用场景和原理。


1. 性能优化的必要性

我们先看一个简单的例子:

function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');

  const list = ['apple', 'banana', 'orange', 'pear'];

  // 每次渲染都会重新执行filter
  const filterList = list.filter(item => {
  // 测试fliter是否执行
  console.log("filter执行了")
  return item.includes(keyword) 
  });

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

这里 filterList 依赖于 keyword,但每次 count 改变导致组件重新渲染时,filterList 也会重新计算。如果 list 很大或者过滤逻辑复杂,这种不必要的计算就会影响性能。同样,如果我们将一个函数作为 prop 传递给子组件,每次父组件渲染都会生成一个新函数,导致子组件(即使使用 React.memo)也无法避免重新渲染。这正是 useMemouseCallback 要解决的问题。

效果图

屏幕录制 2026-03-01 154604.gif

2. useMemo:缓存计算结果

useMemo 用于缓存一个计算后的值,只有当依赖项发生变化时,才会重新计算。

2.1 基本用法

const cachedValue = useMemo(computeFn, dependencies);
  • computeFn:纯函数,返回需要缓存的值。
  • dependencies:依赖项数组,当任意依赖项变化时,重新执行 computeFn

2.2 优化列表过滤

改进上面的例子:

import { useState, useMemo } from 'react';

export default function App() {
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const list = ['apple', 'banana', 'orange', 'pear'];

  // 只有 keyword 变化时才重新过滤
  const filterList = useMemo(() => {
    console.log('filter执行了'); // 依赖变化时打印
    return list.filter(item => item.includes(keyword));
  }, [keyword]);

  return (
    <div>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      <button onClick={() => setCount(count + 1)}>count: {count}</button>
      <ul>
        {filterList.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

现在,点击 count 按钮不会触发 filter 重新执行,只有输入框改变时才会。

效果图

屏幕录制 2026-03-01 154745.gif

2.3 缓存昂贵计算

假设有一个非常耗时的函数 slowSum

function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  for (let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

我们可以用 useMemo 将其结果缓存起来:

const [num, setNum] = useState(0);
const result = useMemo(() => slowSum(num), [num]);

这样只有当 num 变化时才会重新计算,其他状态变化不会触发 slowSum,这样做我们就可以节约成本。

2.4 小结

  • useMemo 适用于需要缓存计算结果的场景,比如过滤、排序、复杂数学运算等。
  • 依赖数组要正确填写,避免遗漏或多余依赖。
  • 不要滥用,如果计算本身不昂贵,就没有必要缓存。

3. useCallback:缓存函数引用

在React中,函数组件每次渲染都会重新创建内部定义的函数。如果这个函数作为 prop 传递给子组件,即使子组件使用了 React.memo,也会因为每次父组件传递的函数引用不同而导致子组件重新渲染。useCallback 就是用来缓存函数引用的。

3.1 基本用法

const cachedFn = useCallback(fn, dependencies);
  • fn:需要缓存的函数。
  • dependencies:依赖项数组,当依赖变化时,重新创建函数。

3.2 配合 React.memo 优化子组件

先看一个没有优化的例子:

import { useState, memo } from 'react';

const Child = memo(({ count, handleClick }) => {
  console.log('子组件渲染了');
  return <div onClick={handleClick}>子组件 count: {count}</div>;
});

export default function App() {
  const [count, setCount] = useState(0);
  const [num, setNum] = useState(0);

  // 每次 App 渲染,这里都会生成一个新函数
  const handleClick = () => {
    console.log('点击了');
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>count+1</button>
      <button onClick={() => setNum(num + 1)}>num+1</button>
      <Child count={count} handleClick={handleClick} />
    </div>
  );
}

虽然 Childmemo 包裹,理论上只有 counthandleClick 变化时才会重新渲染。但由于 handleClick 每次都是新函数,memo 浅比较发现 handleClick 引用变了,导致子组件也会重新渲染。即使点击的是 num 按钮,子组件依然会渲染。

效果图,可以看到当我们点击nums组件时,我们的子组件依然会执行

屏幕录制 2026-03-01 155203.gif

使用 useCallback 缓存 handleClick

const handleClick = useCallback(() => {
  console.log('点击了');
}, []); // 空依赖表示函数永远不会重新创建

现在点击 num 按钮,handleClick 引用不变,子组件就不会重新渲染。

效果图,可以看到这个适合当我们点击nums组件,子组件就不会执行了

屏幕录制 2026-03-01 155340.gif

3.3 依赖项的作用

如果回调函数内部使用了某些状态,需要将这些状态添加到依赖数组中,否则函数内部会一直使用旧的闭包值。

const handleClick = useCallback(() => {
  console.log('当前count:', count);
}, [count]); // 当 count 变化时,重新生成函数

这样既能保证函数引用在 count 不变时稳定,又能在 count 变化时获取最新值。

3.4 小结

  • useCallback 主要用于将回调函数传递给经过 memo 优化的子组件,避免不必要的重绘。
  • 如果回调函数不依赖任何组件状态,依赖数组可以为空。
  • 注意不要过度优化,如果子组件很轻量或者渲染成本很低,不一定需要使用 useCallback

4. useMemo 与 useCallback 的关系

  • useMemo 缓存的是useCallback 缓存的是函数
  • 实际上,useCallback(fn, deps) 等价于 useMemo(() => fn, deps)
  • 两者都是通过闭包和依赖追踪来实现缓存,目的都是减少不必要的计算或渲染。

5. 常见误区与注意事项

5.1 依赖数组要完整

无论是 useMemo 还是 useCallback,都要确保依赖数组中包含了所有在回调/计算中使用的响应式值(props、state、context等)。否则会因闭包捕获旧值而产生 bug。可以使用 eslint-plugin-react-hooks 自动检查依赖。

5.2 不要过早优化

只有在确实存在性能瓶颈时才使用这两个 Hook。滥用会增加代码复杂度,且缓存本身也有开销。可以通过 React DevTools 的 Profiler 来识别需要优化的组件。

5.3 缓存稳定但非“纯”的函数

如果 useCallback 的回调函数依赖于外部状态,但依赖数组为空,则函数内部的变量会一直保持初始值,可能导致 bug。一定要根据实际依赖填写数组。


6. 总结

  • useMemo 用于缓存计算值,避免每次渲染都重新执行昂贵计算。
  • useCallback 用于缓存函数引用,配合 React.memo 避免子组件不必要的重新渲染。
  • 两者都需要指定依赖项,确保缓存内容在依赖变化时更新。
  • 性能优化应当有的放矢,先测量后优化,不要盲目使用。

通过合理使用这两个 Hook,我们可以让 React 应用在复杂场景下依然保持流畅。希望本文能帮助你深入理解 useMemouseCallback,并在实际项目中正确应用它们。


本文代码示例基于 React 18,你可以在自己的项目中尝试并观察效果。如果有任何疑问或见解,欢迎在评论区讨论!

❌