阅读视图

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

你学不会 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! 🚀

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值)。

闭包的主要用途

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

闭包的注意事项

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

为什么现在不推荐使用 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,你可以在自己的项目中尝试并观察效果。如果有任何疑问或见解,欢迎在评论区讨论!

流程设计器为啥选择diagram-js

工作流应用的行业背景

前端工作流可视化的开源js库有很多选择,在具体介绍之前,有必要让大家先了解下目前行业对这块的需求以及薪资情况。因为很多行业的企业针对工作流和规则引擎有定制化需求,能做到对业务人员友好、配置灵活、自动化智能化程度高,这些无疑会给公司的业务扩展、人力成本控制、业绩提升等带来巨大的收益。

这样的背景下,自研工作流平台相关产品,并衍生出一些技术变现的渠道,像自研产品的试用、推销、行业培训、整合应用的在线课程等等,只要令用户满意,都会是一笔客观的收益。为此,身处AI大行其道、技术不再成为产品研发瓶颈的当下,自研工作流产品以及技术整合、应用的视频教程等方面的工作也排入了我今年业余副业规划的日程。

从我15年前开始接触工作流引擎jbpm3到如今,工作流相关的工作岗位可以说一直是某些行业领域的香饽饽,只是定制程度要求更高了,这是我在boss直聘上找到几个典型的招聘要求:

20260301133040.png

流程设计器开源选择

现在工作流的研发向着企业建模定制化和低代码的方向不断迭代和产品升级,这里就不得不提流程可视化的UI建模方面了,恰恰是工作流UI的展示和交互,直接决定了产品的用户选择程度,当然像泛微这样有着强大研发团队的IT企业,他们的流程建模的底层支持可以基于产品的需求做到从零研发定制,市面上研发该类产品更多的选择是,采用开源js库,甚至是现成的UI组件。下面咱们就针对这类选择来聊一聊市面上常用的几款流程可视化前端开源库。

LogicFlow

LogicFlow这是由滴滴出品的用于流程绘制的一套底层UI库,它为企业流程建模和个性化主题扩展方面提供了出色的行业解决方案。从它网站的展示风格就寓示着万物皆可拖拽、万物皆可连结(纯属个人娱乐,非官方解释),绷不住笑~

20260301142424.png

从它官方的图标示例来看,它提供了用于各种用途的场景下绘制需求的demo,尤其是在主题风格的定制化方面。示例的应用场景还在不断完善,从流程图界面的操作体验来感受下,基本的连线操作和布局过于呆板,连接点采用锚点的设计个人觉得甚至有点鸡肋,而且连线布局的路径规划没有做优化,本该一条直连线,在调整图形位置后,会出现许多拐弯,且背景的点阵网格对连接线路径不会限制(不要求路径落在网格点上),基础使用体验上还可以进一步优化下底层功能:

20260301144627.png

关于它的API使用以及功能的扩展,有感兴趣的小伙伴可以自行操作官方指南。因为不作为我的个人选择,不做过多的研究和赘述。本人的流程设计器底层js库的选型要求是,操作便捷流畅,展示简洁大气,对UI包装和扩展上没有过多要求,但要提供优雅的方式进行功能扩展。对比这些要求,我坚定的选择了diagram-js

Workflow

Workflow这是一款个人开发的起名比较大众化的前端流程设计器UI组件,UI仿照钉钉的风格,采用Vue + Element UI打造的直接可用的页面UI组件,Github上提供了相关用法介绍,源码中也有页面使用示例,方便与项目集成,它还提供了页面演示地址。从视觉和操作体验上来看,布局过于呆板,垂直的布局让屏幕空间利用率很低,节点和连线都是固定的,页面显示要素过多,不够简洁:

20260301150308.png

抽屉式的属性编辑弹层,操作像卡壳了(绷不住笑)。 当然该项目很久就不再维护了,看到某站有up用它来研发规则决策可视化平台,才留意到这个UI库。因为布局过于固执己见,这是硬伤。

diagram-js

diagram-js这个js库很多人都没听过,如果提起基于它的产品bpmn-js,似乎是无人不知无人不晓(绷不住笑)。
bpmn-js这么成功,就是因为它的底层图形库:diagram-js,它可是国外众多前端大佬贡献的结晶。个人觉得越是好的东西,宣传越是不当回事(绷不住笑)。
这里重点说下咱用这个库做出来的一点小小的成果。而关于这块的学习教程,看各位读者的反馈吧。我先整理了第一部分的笔记:

20260301152207.png

有一定的学习曲线,因为没有系统的文档和使用场景,如果有耐心可以像我一样读diagram-js和bpmn-js的源码,如果觉得麻烦,后续我会安排上《diagram-js打造企业产品级流程设计器》的视频教程(绷不住笑出声)。

基于diagramjs打造产品级流程设计器

目前个人觉得做的最成功的流程设计器,还属bpmn.io出品的工具集三大件(bpmn-js、dmn-js和form-js)之一的bpmn-js。只是该产品是完全遵循bpmn规范的,对于本文一开始提到的国内使用场景,需要做到高度的模型定制化,因此本人才业余潜心研究这个底层库,向着产品化的目标不断的学习和实践。从初步实践中,发现这个库的几个显著的特色:

  • 库很轻量级,不依赖外部任何库,写原生js就可以轻易扩展
  • 内置模块功能足够的强大
  • 模块即插即用,且模块功能叠加使用下,可相互作用得到更好的结果
  • 模块扩展非常方便、定制和覆盖功能有着非常优雅的解决方案

第一部分实现的成果,发一个粗糙的截图:

20260301154555.png 但话说美的东西不都是从最简单的东西一点点布局修饰出来的吗(绷不住笑),后续会以录屏的方式给大家分享在某站,以链接的形式发出来。

Vue3 项目如何迁移到 uni-app x:从纯 Web 到多端应用的系统指南

一、引言:为什么要从「纯 Vue3 Web」迁移到 uni-app x?

许多团队已经有了一套成熟的 Vue3 Web 项目(基于 Vite、Vue Router、Pinia 等),跑在浏览器里一切正常。但随着业务发展,往往会遇到这些新需求:

  • 需要上线微信/支付宝/抖音等小程序入口;
  • 需要有一套「原生 App」承载更重的功能(推送、离线、深度系统能力);
  • 维护多套代码(Web、一堆小程序、原生 App)成本太高。

uni-app x 的目标就是:让你继续写 Vue3 + TS 风格的代码,但可以一套工程覆盖 App + 各类小程序 + H5。
因此,对已有 Vue3 项目来说,一个自然的问题就是:

如何在不推倒重来的前提下,尽量平滑地迁移到 uni-app x?

本文将从整体策略、目录结构改造、路由/状态管理适配、组件与 API 替换等方面,给出一套可操作的迁移思路和步骤,并分析过程中可能的坑与注意点。


二、迁移前评估:先搞清楚自己是什么项目

迁移前不要急着动手,先回答几个关键问题:

  1. 当前项目的技术栈

    • 是否使用:Vite、Vue Router、Pinia 或 Vuex、Axios、Element Plus/Ant Design Vue 等?
    • 是否大量使用 DOM 直接操作、windowdocument 等 Web 专属 API?
  2. 业务复杂度与依赖

    • 是否大量依赖第三方 UI 库、图表库(ECharts、AntV)、富文本编辑器、复杂表格等?
    • 是否有强 Web 特性(如 iframe、浏览器插件接口、localStorage 逻辑等)?
  3. 迁移目标平台

    • 必须支持哪些:App(iOS/Android)/ 微信小程序 / 其他小程序 / H5?
    • 是否对某些平台有特别强的能力诉求(如推送、蓝牙、相机、文件系统)?
  4. 时间和人力约束

    • 能不能接受一段时间的「双线维护(旧 Web + 新 uni-app x)」?
    • 是否有安卓/iOS 原生同事能协助插件层能力?

根据评估结果,可以大致判断:

  • 适合重用大量业务逻辑,只做外壳改造
  • 还是必须进行较重的架构重构(比如完全脱离 DOM 思维)。

三、迁移总体策略:不要一下子「全搬」,而是分层解耦

从纯 Web(Vue3 SPA)到 uni-app x,本质上是从:

Vue3 + Router + Web DOM + 浏览器特性
          ↓
Vue3 + uni-app x 组件体系 + 多端(App/小程序/H5

迁移的关键策略是:

先把“与平台强绑定”的部分(路由、UI、API)剥离出来,把“与业务有关”的逻辑、数据、服务层抽出来复用。

可以按「三层架构」来思考:

  1. 业务逻辑层(可高度复用)

    • 接口请求封装(API Service)
    • 业务状态管理(Pinia Store)
    • 领域模型与工具函数(utils, hooks)
  2. 页面 & 组件层(部分复用,需要适配)

    • 原有的 .vue 页面可以搬过去,但需要调整:

      • DOM 标签 -> uni-app 组件(div -> viewspan -> text 等)
      • UI 库替换或重构(Element Plus -> 移动端自定义 UI / uni UI 等)
  3. 基础设施层(需重构)

    • 路由:Vue Router -> uni-app 页面路由机制
    • 运行环境:浏览器 -> 多端运行(小程序/App/H5)
    • 全局入口:main.ts -> App.vue + pages.json

四、实际迁移步骤:从创建 uni-app x 项目开始

4.1 步骤 1:新建一个 uni-app x 项目骨架

使用 HBuilderX 或 CLI 创建一个 uni-app x 项目(以 CLI 为例,命令以官方最新文档为准,下面用伪示例):

# 假设已有相关 CLI 工具
npx degit dcloudio/uni-app-x-starter my-uniappx-app
cd my-uniappx-app
pnpm install # 或 npm/yarn

项目结构通常类似:

my-uniappx-app
├─ src
│  ├─ pages
│  │  └─ index
│  │     └─ index.vue
│  ├─ App.vue
│  ├─ main.ts
│  └─ ...
├─ pages.json
├─ manifest.json
└─ ...

先跑通基础项目(例如 H5 或 App 模拟器),确保环境与编译没问题。

4.2 步骤 2:抽取原项目的「可复用业务层」

在原 Vue3 项目中,重点抽离这些:

  1. services/api/:接口封装
  2. stores/:Pinia 或 Vuex
  3. utils/:通用工具函数
  4. 纯 TS/JS 模块:与平台无关的业务逻辑

将它们复制到新项目的 src/shared/(或任意你喜欢的目录名),例如:

src
├─ shared
│  ├─ api
│  │  └─ user.ts
│  ├─ stores
│  │  └─ user.ts
│  ├─ utils
│  │  └─ date.ts
│  └─ types
│     └─ user.ts
├─ pages
│  └─ index
│     └─ index.vue
└─ ...

4.2.1 网络请求封装适配

如果原来使用 axios,有两种做法:

  • 做一个轻薄的适配层:内部根据运行环境调用 uni.requestfetch
  • 或者直接改用 uni.request + 自己封装

示例(简化版):

// src/shared/api/request.ts
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

interface RequestOptions<T = any> {
  url: string
  method?: HttpMethod
  data?: Record<string, any>
  headers?: Record<string, string>
}

export function request<T = any>(options: RequestOptions): Promise<T> {
  const { url, method = 'GET', data, headers } = options

  return new Promise((resolve, reject) => {
    uni.request({
      url,
      method,
      data,
      header: headers,
      success: (res) => {
        // 根据你后端返回格式处理
        const data = res.data as any
        if (data.code === 0) {
          resolve(data.data as T)
        } else {
          reject(new Error(data.message || 'Request error'))
        }
      },
      fail: (err) => {
        reject(err)
      }
    })
  })
}

原来用 axios.get('/user') 的地方,就改成使用这个 request 封装。

4.2.2 状态管理:Pinia 基本可以直接复用

uni-app x 基于 Vue3,使用 Pinia 通常是可行的。只需在 main.ts 中按 Vue3 方式挂载:

// main.ts(uni-app x 项目)
import { createSSRApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'

export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  app.use(pinia)
  return { app }
}

原项目的 Pinia store 代码几乎可以原样搬过来,如:

// src/shared/stores/user.ts
import { defineStore } from 'pinia'
import type { UserInfo } from '../types/user'
import { fetchUserInfo } from '../api/user'

export const useUserStore = defineStore('user', {
  state: (): { info: UserInfo | null } => ({
    info: null
  }),
  actions: {
    async loadUser() {
      this.info = await fetchUserInfo()
    }
  }
})

在 uni-app x 的页面里正常使用即可:

import { useUserStore } from '@/shared/stores/user'

const userStore = useUserStore()
userStore.loadUser()

4.3 步骤 3:重构路由结构:Vue Router -> pages.json

原 Vue3 SPA 中典型的路由配置大致是:

// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import User from '@/views/User.vue'

const routes = [
  { path: '/', name: 'Home', component: Home },
  { path: '/user', name: 'User', component: User }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

在 uni-app x 中,不使用 Vue Router 管理页面路由,而是:

  • pages.json 声明页面;
  • 使用 uni.navigateTo / uni.redirectTo / uni.switchTab 等 API 进行跳转。

例如:

// pages.json
{
  "pages": [
    {
      "path": "pages/home/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/user/index",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "tabBar": {
    "color": "#666666",
    "selectedColor": "#007aff",
    "borderStyle": "black",
    "backgroundColor": "#ffffff",
    "list": [
      {
        "pagePath": "pages/home/index",
        "text": "首页",
        "iconPath": "static/tab-home.png",
        "selectedIconPath": "static/tab-home-active.png"
      },
      {
        "pagePath": "pages/user/index",
        "text": "我的",
        "iconPath": "static/tab-user.png",
        "selectedIconPath": "static/tab-user-active.png"
      }
    ]
  }
}

跳转示例:

// 在页面脚本中
const goUser = () => {
  uni.navigateTo({ url: '/pages/user/index' })
}

如果你原来大量依赖「编程式路由 + 命名路由 + 路由守卫」,需要:

  • 全局守卫逻辑(如登录校验)转移到:

    • 页面生命周期(onLoadonShow)里做校验;
    • 或封装为导航函数:goUserPage() 里统一判断登录态。
  • 路由参数route.params / route.query 改到:

    • onLoad((options) => {...}) 中的参数;
    • 或通过 uni.navigateTo({ url: '/pages/detail/index?id=123' }) 传 query。

4.4 步骤 4:页面 & 组件改造:从 DOM -> uni-app 组件体系

这是最费时的部分,但也是「迁移成败的关键」。

4.4.1 基础标签替换

常见的替换规则(示意):

Web (Vue3) 标签 uni-app 推荐标签 说明
div view 通用容器
span text 行内文本
img image 图片,支持多端能力
a navigator / view+跳转 页面跳转,用 uni.navigateTo
button button(uni 组件) 支持表单、权限等能力
input input(uni 组件) 不同平台封装
textarea textarea 多行输入

示例:原 Web 代码(简化):

<template>
  <div class="card" @click="goDetail(item.id)">
    <img :src="item.cover" class="cover" />
    <div class="info">
      <span class="title">{{ item.title }}</span>
      <span class="desc">{{ item.desc }}</span>
    </div>
  </div>
</template>

迁移到 uni-app x:

<template>
  <view class="card" @click="goDetail(item.id)">
    <image :src="item.cover" class="cover" mode="aspectFill" />
    <view class="info">
      <text class="title">{{ item.title }}</text>
      <text class="desc">{{ item.desc }}</text>
    </view>
  </view>
</template>

提示:

  • 避免使用原生 DOM 相关 API(document.querySelector 等),改为 Vue 的响应式 + uni 组件能力。
  • 样式方面继续使用 rpx、flex 等,但要注意小程序与 H5 对部分 CSS 特性的支持差异。

4.4.2 UI 组件库的处理

如果原项目使用了 Element Plus / Ant Design Vue / View UI 等「PC Web UI 库」,一般:

  • 不建议直接迁移:这些 UI 库大多为 PC/H5 设计,不适合 App/小程序体验和尺寸;

  • 建议:

    • 为移动端重新选择 uni-app/uni-app x 生态内的 UI 库(如 uView、uni-ui 等,看后续对 x 的适配);
    • 或自行封装一套轻量 UI 组件库(Button、Cell、List、Dialog、Toast 等)。

迁移策略:

  1. 先识别项目中常用 UI 组件类型:表单、列表、弹窗、Tabs、Drawer 等;

  2. 在 uni-app x 项目中统一封装一层「业务 UI 组件库」,即便内部暂时用原生 view + text 拼:

    • 例如 src/components/base/Button.vueDialog.vue 等;
  3. 业务页面只依赖这套「业务 UI 组件库」,未来要换实现也方便。


4.5 步骤 5:平台相关 API 替换:window/document -> uni.*

原来的 Vue3 Web 项目,常见这些写法:

  • window.localStoragesessionStorage
  • window.location
  • document.title = 'xxx'
  • 监听 window.addEventListener('resize', ...)

在 uni-app x 里,要换成跨端封装的方式,例如:

  1. 本地存储

    • 使用 uni.setStorageSync / uni.getStorageSync
    • 封装一个 storage 工具:
    // src/shared/utils/storage.ts
    const TOKEN_KEY = 'TOKEN'
    
    export function setToken(token: string) {
      uni.setStorageSync(TOKEN_KEY, token)
    }
    
    export function getToken(): string | null {
      const t = uni.getStorageSync(TOKEN_KEY)
      return t || null
    }
    
    export function clearToken() {
      uni.removeStorageSync(TOKEN_KEY)
    }
    
  2. 页面标题

    • pages.json 通过 navigationBarTitleText 设置;
    • 或调用:uni.setNavigationBarTitle({ title: 'xxx' })
  3. 窗口尺寸与滚动监听

    • 使用 uni.getSystemInfo / uni.onWindowResize(不同端支持情况略有差异,要查文档);
    • 滚动监听通过 scroll-view / 页面滚动事件实现,而非直接 DOM 监听。

4.6 步骤 6:分阶段验证与发布策略

不要等「全项目迁移完」才开始验证,多阶段、小步快跑更靠谱:

  1. 阶段 1:最小可运行版本

    • 至少有 1–2 个核心页面在 uni-app x 中可运行(H5 & 小程序/App 模拟器都跑通);
    • 关键业务流程走通(登录 -> 主页 -> 某个主要业务)。
  2. 阶段 2:模块化迁移

    • 按业务模块迁移,例如「用户中心模块」「订单模块」;
    • 每迁移完成一个模块,就在测试环境整体验证。
  3. 阶段 3:灰度发布与 AB 测试(如果条件允许)

    • 对移动端入口,逐渐导入一部分用户到新 uni-app x 客户端或小程序;
    • 收集性能表现、崩溃率、用户反馈。
  4. 阶段 4:旧 Web 项目收缩职责

    • 慢慢把纯 Web SPA 项目的核心功能剥离,只留下必要的 PC Web 功能;
    • 移动端流量逐步切到 uni-app x 提供的多端入口。

五、迁移过程中的常见坑与应对

5.1 拼命想要「完全复用」原模板代码

很多同学迁移之初,会希望 .vue 页面一个字都不要改地搬过来,这通常是做不到的,主要原因:

  • 标签体系不同:div/spanview/text 的语义和能力不同;
  • CSS 差异:小程序端对部分 CSS 支持不全;
  • DOM API 不存在:uni-app 环境下没有真实 DOM。

建议
接受「逻辑可以高复用,UI 层需要适配」这个事实,提前预估这部分工作量。

5.2 忽略小程序/App 端的权限与能力差异

  • 比如:文件下载、打开外链、支付、登录态管理,在小程序/App/H5 上都有差异;
  • 不要把它们揉在一起写,建议封装为:
// src/shared/utils/platform.ts
export function isWeixinMiniProgram(): boolean {
  // 参考 uni-app 平台判断写法
  // #ifdef MP-WEIXIN
  return true
  // #endif
  return false
}

再在业务逻辑里按平台区分处理。
有条件可以统一封装 service:例如 pay(order) 内部再根据平台调用不同实现。

5.3 图表、富文本等第三方库的适配

  • ECharts/AntV 等在小程序 & App 端需要专门的 Canvas/组件适配;
  • 富文本编辑器在移动端、小程序生态差异很大。

建议

  • 优先搜寻「uni-app/uni-app x 生态中已有的适配方案或组件库」;
  • 实在没有,考虑为 App 和小程序端写专门版本,或者功能做轻量降级。

六、总结:从 Vue3 到 uni-app x 的核心经验

整体回顾:

  1. 不要从“Vue3 -> uni-app x”直接想,而是从「Web-only -> 多端」的角度思考

  2. 成功迁移的关键在于:

    • 抽离业务逻辑层(API、Store、Utils),尽可能与平台解耦;
    • 重构 UI 与路由层,接受一定程度的「模版和样式重写」;
    • 使用 uni-app/uni-app x 提供的跨端 API 替代浏览器专属能力。

对大多数中小团队来说,迁移的回报是:

  • 从一个只能跑在浏览器里的 Vue3 SPA,升级成一套可覆盖 App + 小程序 + H5 的多端应用;
  • 在后续需求演进中,「新平台支持」会变成「配置和适配问题」,而不是「新项目问题」。

uni-app x 发展前景技术分析:跨端统一的新阶段?

一、引言:从「一套代码多端运行」到「真正的跨端统一」

过去几年,前端与移动开发领域围绕「跨端」已经卷了很多轮:

  • 从传统 H5 + WebView 的 Hybrid 方案
  • 到 React Native、Weex 这类 JS + 原生渲染
  • 再到 Flutter、Hippy 等自绘 UI 引擎
  • 以及 uni-app、Taro、mpx 等小程序多端框架

uni-app x 是 DCloud 在 uni-app 基础上的一次「重构级」升级,目标并不是简单地再做一个多端框架,而是通过统一渲染引擎、统一语法能力、强化原生性能,在 Web、小程序、App 原生等多端之间真正实现「同一套代码,体验接近原生」。

它试图解决的核心问题有三个:

  1. 性能瓶颈:传统 uni-app(基于 WebView 渲染)在复杂交互、动画、长列表场景下性能受限。
  2. 多端差异:各家小程序、App 端能力差异大,开发者频繁写条件分支、做兼容。
  3. 技术演进:在 Vue3、Vite、TypeScript、原生生态更新的背景下,原有体系的扩展性不足。

本文将围绕 uni-app x 的技术特点、实现思路和实际使用体验,分析它的技术路线、优缺点以及未来发展前景,并给出一些落地建议。


二、背景与问题:传统 uni-app 与跨端方案的痛点

2.1 传统 uni-app 的架构与局限

传统 uni-app(下文称 uni-app classic)构建在以下基础之上:

  • 开发语言:Vue2(支持 Vue3 但生态偏 Vue2)

  • 运行环境:

    • H5 端:标准 Web 环境
    • 小程序端:编译为各家的小程序语法(微信/支付宝/抖音等)
    • App 端:基于 WebView(plus/webview)+ 原生能力(plus.* API)
  • 渲染方式:以 WebView 为主,JS 代码跑在 JS 引擎中,UI 通过 DOM/CSS 渲染

这套方案的优点是:

  • 成本低:基于成熟 HTML/CSS/JS 能力
  • 适配广:可以覆盖各种小程序、H5 和 App
  • 生态丰富:大量 uni-app 组件、插件、uView、uCharts 等库可用

但也不可避免地存在这些痛点:

  1. App 端性能不足

    • 复杂动画掉帧明显
    • 列表滚动、滚动吸顶、骨架屏等体验不够丝滑
    • 与 Flutter、原生 App 相比差距明显
  2. 多端差异仍然明显

    • 不同小程序的组件和 API 差异大,框架做了统一封装,但边缘场景仍要写 #ifdef
    • 部分平台独占能力无法平滑兼容(如某些支付、推送、系统级能力)
  3. 架构年代感

    • 早期设计受 Vue2 + WebView 限制,面对 Vue3、Composition API、TypeScript 深度整合时显得笨重
    • App 端想要引入更接近 Flutter、RN 的渲染机制时阻力较大

在这样的背景下,仅对 uni-app 做增量优化,很难带来质的提升。于是有了uni-app x


三、uni-app x 的核心技术思路与实现

注意:官方在迭代中可能持续更新名字与特性,以下基于公开资料与通用跨端技术趋势做结构化解读,重点在技术路径而非具体版本号。

3.1 uni-app x 的总体目标

可以概括为:

在保持 uni-app「一套代码、多端覆盖」优势的前提下,引入更接近原生和 Flutter 的渲染性能与开发体验。

具体体现为:

  • 渲染层:引入统一的跨端渲染引擎(非简单 WebView DOM)
  • 语法层:向 Vue3、TS 友好,优化开发体验
  • 能力层:加深与原生能力的集成,降低“JS 调原生”的心智和性能成本

3.2 架构:从 WebView 到「跨端渲染引擎」

传统 uni-app App 端架构简化可以写成:

Vue (JS) --> WebView (HTML/CSS) --> plus.* 原生能力

而 uni-app x 的典型跨端架构更接近:

Vue/TS (JS) --> 虚拟 DOM / Fiber 层 --> 跨端渲染引擎 --> 原生控件 / 自绘渲染

其中关键点在于:

  1. 虚拟 DOM 与 UI 描述从 DOM 解耦

    • 你的 template 不再仅仅是 HTML 的映射,而是抽象 UI 树
    • 渲染引擎可以根据平台把这棵 UI 树映射为原生控件树或自绘视图树
  2. 渲染引擎负责平台差异

    • 在 Android 上可以使用 RecyclerViewViewGroup 等组合
    • 在 iOS 上使用 UIViewUICollectionView 等组合
    • 在 Web/H5 上降级为 DOM 渲染
    • 在小程序中映射为其自有组件系统
  3. JSBridge 优化

    • 通过批量 diff 更新、异步队列减少「JS <-> Native」的频繁通信
    • 典型方案类似 RN、Flutter 的 batch update / message queue 模型

一个抽象的 UI 渲染过程示意

flowchart LR
  A[Vue 组件] --> B[模板编译 & 响应式系统]
  B --> C[虚拟 UI 树 (VNode)]
  C --> D[Diff & Patch 层]
  D --> E[跨端渲染引擎]
  E --> F1[Android 原生控件]
  E --> F2[iOS 原生控件]
  E --> F3[Web DOM / Canvas]
  E --> F4[小程序组件]

3.3 基于 Vue3 + Composition API + TypeScript

为了更好地支持下一代前端生态,uni-app x 通常会以Vue3 生态为优先,例如:

  • script setup 语法
  • Composition API(refreactivecomputed 等)
  • TypeScript 类型推导与 IDE 支持
  • Vite / Rollup 构建体系

示例(伪代码,结构风格接近实际 uni-app x):

<template>
  <view class="page">
    <view class="header">
      <text class="title">uni-app x Demo</text>
    </view>
    <scroll-view class="list" scroll-y>
      <view v-for="item in list" :key="item.id" class="list-item">
        <image :src="item.cover" mode="aspectFill" class="cover" />
        <view class="content">
          <text class="name">{{ item.name }}</text>
          <text class="desc">{{ item.desc }}</text>
        </view>
      </view>
    </scroll-view>
    <button class="fab" @click="addItem">新增一条</button>
  </view>
</template>

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

interface Item {
  id: number
  cover: string
  name: string
  desc: string
}

const list = ref<Item[]>([
  { id: 1, cover: '/static/cover1.png', name: '示例 1', desc: '说明文字 1' },
  { id: 2, cover: '/static/cover2.png', name: '示例 2', desc: '说明文字 2' }
])

let id = 3

const addItem = () => {
  list.value.push({
    id: id++,
    cover: '/static/cover-new.png',
    name: `示例 ${id}`,
    desc: `新增的说明文字 ${id}`
  })
}
</script>

<style scoped>
.page {
  flex: 1;
  background-color: #f5f5f5;
}
.header {
  padding: 20rpx 30rpx;
  background-color: #007aff;
}
.title {
  color: #fff;
  font-size: 34rpx;
  font-weight: 600;
}
.list {
  height: calc(100vh - 100rpx);
}
.list-item {
  display: flex;
  padding: 20rpx;
  margin: 20rpx;
  border-radius: 16rpx;
  background-color: #fff;
}
.cover {
  width: 160rpx;
  height: 160rpx;
  border-radius: 12rpx;
}
.content {
  flex: 1;
  margin-left: 20rpx;
}
.name {
  font-size: 32rpx;
  font-weight: 500;
}
.desc {
  margin-top: 10rpx;
  font-size: 26rpx;
  color: #999;
}
.fab {
  position: fixed;
  right: 40rpx;
  bottom: 80rpx;
  width: 120rpx;
  height: 120rpx;
  border-radius: 60rpx;
  background-color: #ff9500;
  color: #fff;
}
</style>

上述代码在 uni-app x 下,可以被编译到多端,底层由新的渲染管线完成实际 UI 构建,性能会优于传统 WebView DOM 渲染。

3.4 原生能力与插件生态的强化

uni-app 一大优势在于 App 端可通过 native.js、原生插件、plus.* API 接入本地能力。uni-app x 的发展方向主要包括:

  1. 统一原生插件模型

    • 提供一套更现代、更 TS 友好的插件接口定义
    • 把原生模块能力映射为 JS/TS 中的模块(类似 RN 的 Native Module)
  2. 降低 JS/Native 交互成本

    • 通过序列化策略、批量调用机制减少开销
    • 部分高频能力(如滚动监听、手势)下沉到引擎内部执行,JS 只关心结果事件
  3. 与操作系统特性同步

    • 支持最新的 Android/iOS SDK 能力:权限、拍照相册、多媒体、蓝牙、NFC 等
    • 深度集成推送、剪贴板、文件系统、通知等系统级功能

一个典型的调用示例(伪代码):

import { useCamera } from '@dcloudio/uni-camera-x'

const camera = useCamera()

const takePhoto = async () => {
  try {
    const res = await camera.capture()
    console.log('photo path: ', res.path)
  } catch (e) {
    console.error('capture error: ', e)
  }
}

底层由 uni-app x 的 Native 模块完成设备调度、权限检查等逻辑,开发者保持较为统一的业务代码。


四、技术优缺点分析与实践建议

4.1 优点分析

4.1.1 性能和体验更接近原生

  • 列表滚动、复杂动画更流畅:

    • 非 DOM 渲染,更多使用平台原生控件或自绘,减少中间层
    • 布局计算、绘制更贴合平台原生 pipeline
  • 减少 WebView 局限

    • 无需处理部分 WebView bug(如某些机型滚动抖动、输入法遮挡)
    • 更容易做像素级布局控制

在对性能和体验敏感的场景(如内容信息流、IM、互动页面)中,是明显优势。

4.1.2 统一多端语法和能力,提高复用度

  • 更统一的组件体系:viewtextimage 等基础组件在 App、小程序、H5 端表现更一致。
  • 统一的 API 接口:如网络、存储、路由、系统能力等,对多端做了封装与降级处理。
  • 通过工程化与插件系统扩展能力,用「一套工程」覆盖更多平台。

对于中大型团队而言,这可以显著降低「端上差异」导致的维护成本。

4.1.3 紧跟 Vue3/TS 等前端主流技术

  • script setup 结构更简洁
  • TS 类型与 IDE 支持让业务代码更可靠
  • 可组合 API 便于抽象可复用逻辑(如 useAuthuseRequestuseStore 等)

这对前端工程师极为友好——学习成本低,转化效率高

4.2 缺点与挑战

4.2.1 生态迁移成本与兼容问题

  • 旧的 uni-app 插件与组件库,可能需要适配或升级才可在 uni-app x 上使用。

  • 现有项目如要「无痛迁移」,往往做不到,需有一段双线维护期:

    • 一条线继续用 uni-app classic 维护现网
    • 一条线尝试 uni-app x 做新需求或新版本

建议

  • 新项目可直接评估是否上 uni-app x。
  • 旧项目要做详细成本评估再决定是否迁移,特别是插件依赖多的项目。

4.2.2 学习与调试心智负担

  • 虽然对开发者暴露的是 Vue 语法,但底层已经不是 DOM,部分 Web 习惯(如某些 CSS 特殊写法、DOM API)可能不再适用,需要重新理解:

    • 例如:不能直接使用 document.getElementById 之类 Web API
    • 某些 CSS 特性支持情况与 Web 有差异
  • 调试工具链、性能分析工具需要适应新的渲染架构,前期资料和社区经验积累可能不足。

4.2.3 与 Flutter/React Native 的竞争与对比

从技术路线看,uni-app x 与 Flutter/RN 在 App 端开发上有一定「竞合」关系:

  • Flutter 优点:性能极佳、自绘 UI、一套 Dart 代码多端

  • RN 优点:React/JS 生态强大,Facebook 维护

  • uni-app x 优点

    • 对中国小程序生态有较好支持
    • Web 和各端小程序的多端覆盖能力比 Flutter/RN 更强
    • 使用 Vue/TS,对已有 uni-app/Web 开发者更友好

但在极致性能场景(例如 3D 游戏、复杂图形渲染)上,Flutter/RN 仍有明显优势。uni-app x 的定位更像是:

以业务应用、内容应用、企业级应用为主,追求「足够好」的原生体验 + 极高的多端复用效率。

4.3 实际应用场景与选型建议

4.3.1 推荐使用 uni-app x 的场景

  • 新立项的中大型业务 App,需要同时支持:

    • 至少一个主流 App 应用(Android/iOS)
    • 1–2 个小程序(例如微信 + 支付宝)
    • H5 备用入口
  • 需要较好性能但不追求极致游戏级体验的场景:

    • 内容资讯流、社交/社区 App
    • 电商、教育类应用
    • 企业内部管理、SaaS 移动端
  • 团队已有 uni-app/Vue 前端基础,希望升级到更现代技术栈,同时长期维护一个统一代码仓库。

4.3.2 谨慎或暂缓采用的场景

  • 已有一个成熟、复杂度极高且紧耦合原生的 uni-app classic 项目:

    • 如有大量原生插件,且当前方案已相对稳定
    • 短期主要目标是维护与小改,非重构
  • 极致性能/图形场景:

    • 复杂 3D 场景、游戏、AR/VR
    • 这类场景更建议选用 Flutter + 原生、Unity 等方案
  • 团队整体原生技术占比高,对 Vue/JS 并不熟悉,且未来以原生项目为主。

4.3.3 落地实践步骤建议

  1. 从新功能或新模块试水

    • 不要一上来就全项目迁移
    • 可以选择一个新模块(如新活动、独立子应用)用 uni-app x 开发,验证性能与工程体验
  2. 抽象跨项目可复用的基础层

    • 例如:

      • UI 规范封装成组件库:按钮、导航栏、卡片、弹窗
      • 通用 hooks:useRequestuseUseruseEnv
      • 网络、日志、埋点等基础设施
  3. 原生插件策略

    • 待观察 uni-app x 原生插件市场是否成熟

    • 自研的核心原生插件,设计为「跨框架可复用」:

      • 将底层能力以标准原生 SDK 的形式封装
      • 再针对 uni-app x/Flutter/RN 等封一层适配

五、未来发展前景分析

5.1 技术趋势维度

  1. 跨端框架仍将是刚需

    • 企业不可能在所有平台都养独立团队做纯原生
    • 跨端框架的存在是「成本与效率」的必然平衡
  2. 「新一代渲染架构」会成为主流

    • Flutter 已证明自绘引擎路线可行
    • RN 新架构(Fabric)也在优化 JS-Native 通信
    • uni-app x 引入新渲染引擎顺应这一趋势
  3. Vue3/React + TS 成为前端事实标准

    • uni-app x 若能持续对齐 Vue3 生态,天然享受前端技术红利

5.2 市场与生态维度

uni-app 在国内小程序/H5/App 多端框架中占有量较高,具备以下基础:

  • 丰富的插件市场与生态
  • 大量存量 uni-app 项目与开发者
  • 深度本地化(中文文档、社区、运维支持)

如果 uni-app x 能做到:

  • 提供平滑的迁移路径
  • 持续提升稳定性与性能
  • 保持与各大小程序平台、Android/iOS 新版本的同步升级

那么在未来 3–5 年,在国内多端低门槛应用开发市场中持续占据重要位置是可预期的。

5.3 风险与变数

  • 与 Flutter、RN、Taro 等竞品的技术竞合,会影响其在部分新项目中的选型份额。

  • 需要时间沉淀生态:

    • 第三方组件库、UI 库、数据可视化库对 uni-app x 的适配情况
    • 开发者踩坑经验与文档完善程度

但从目前的发展轨迹和需求结构看,只要官方持续投入,uni-app x 作为 uni-app 体系的「下一代主力」的可能性相当大。


六、结论:uni-app x 的实际价值与未来定位

综合来看,uni-app x 是 DCloud 在 uni-app 基础上的一次架构升级和技术迭代,其核心价值可以概括为:

  1. 性能更好:通过新的跨端渲染架构,更接近原生体验,弥补了传统 uni-app 在 App 端的主要短板。
  2. 技术栈现代化:与 Vue3、TypeScript、Vite 等前端主流技术生态对齐,提升开发效率与可维护性。
  3. 多端统一能力强化:保持了 uni-app 在小程序 + H5 + App 多端统一方面的传统优势,并进一步降低多端差异成本。
  4. 适合中长期项目投资:对于规划 3 年以上生命周期的应用,使用 uni-app x 能在未来兼顾性能、维护成本和团队人才结构。

未来几年,如果你所在的团队:

  • 需要做「一套代码、多端上线」的业务
  • 对体验有一定要求但不追求极致游戏级性能
  • 团队以 Web/Vue 技术栈为主

那么把 uni-app x 纳入你的主流技术选型列表,是一个非常值得考虑的决策。


七、参考资料与延伸阅读(可选)

注:具体链接可能随时间调整,可通过关键词在官网或 GitHub 搜索最新版本。

  1. 官方文档与指南

    • DCloud uni-app 官网:

      • 关键字:「uni-app 官网」「uni-app x 文档」
    • 官方 GitHub 仓库(包含示例与 issue 讨论)

  2. 跨端技术原理与比较

    • 关键词建议:

      • 「跨端框架技术对比:uni-app vs Taro vs Flutter vs React Native」
      • 「JSBridge 通信机制原理」
      • 「跨平台渲染引擎架构(Flutter/RN/Weex)」
  3. Vue3 与 TypeScript 学习资料

    • Vue3 官方文档(中文):关键词「Vue3 文档」
    • TypeScript 官方文档与中文教程:关键词「TypeScript 中文网」
  4. 性能与调试实践

    • 搜索关键字:

      • 「uni-app 性能优化实践」
      • 「Vue3 性能调优」
      • 「移动端长列表优化技巧」

我的 Monorepo 实践经验:从基础概念到最佳实践

本文将系统地整理我在 Monorepo 和前端工程化方面的一些实践经验,先简单介绍几个概念。

Monorepo(单一代码仓库):Monorepo 是将多个应用和共享库放在同一个仓库中进行管理。与多仓库模式相比,Monorepo 更加高效,尤其在代码共享、依赖升级和跨项目协作方面。

工程化:通过规范、工具链和流程,把“能跑”提升到“可维护、可协作、可持续交付”。它覆盖的不只是开发,还包括构建、测试、发布、质量保障等完整生命周期。

Monorepo 里最关键的抽象:应用包与库包

应用包(App)

应用包是最终会被部署的项目,通常放在 apps/*,例如 Web、Admin、Docs、BFF 等。

库包(Package)

库包用于复用能力,通常放在 packages/*,本身一般不直接部署,而是被应用包消费。

在 Monorepo 里,库包常见有三种策略:

  1. 可发布包(Publishable Package)
  2. 预构建包(Compiled Package)
  3. 源码引用包(Source Package)

选择哪种策略取决于具体场景,没有绝对的优劣之分。

三种库包策略介绍

1) 可发布包:面向仓库外复用

如果这个包是要提供给外部团队或开源用户使用,那么应该采用可发布包的方式。

优点:

  • 对外分发标准清晰,边界明确
  • 可独立版本化,兼容性管理更规范

代价:

  • package.json 字段配置更复杂(nameexportstypesfilespublishConfig 等)
  • 可能需要考虑 CJS/ESM 的导出兼容与消费方式(如 import/require、不同 bundler 解析差异)
  • 需要维护发版流程、版本语义和变更记录
  • 在仓库内频繁迭代时,版本与锁文件更新会增加心智负担

2) 预构建包:面向仓库内复用、兼顾稳定与性能

预构建包首先会构建出 dist 文件,应用包再消费这些构建结果。

优点:

  • 应用包构建更加稳定,模块之间的边界更加清晰
  • 减少重复转译,尤其在大仓库中

代价:

  • 需要维护 build 步骤与产物一致性
  • 调试链路比直接使用源码更长

一个典型配置:

{
  "name": "@workspace/utils",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./dist/index.js"
    }
  },
  "scripts": {
    "build": "tsc"
  }
}

在工作区里引用通常用:

{
  "dependencies": {
    "@workspace/utils": "workspace:*"
  }
}

3) 源码引用包:开发体验优先

源码引用包直接导出 src/*.ts(x),由应用包的构建工具(如 Vite、Webpack等)完成转译。

优点:

  • 配置简单,改完即生效,开发体验好
  • 少一层“先打包再消费”的步骤

代价:

  • 应用包需要承担类型检查和转译的成本
  • 对 TypeScript 配置一致性要求更高

示例:

{
  "name": "@workspace/utils",
  "exports": {
    "./tool": "./src/tool.ts"
  }
}

这种模式通常不需要 build 脚本,但建议保留独立的类型检查脚本(例如 typecheck)。

很多团队最终会混用这三种策略:

  • UI 组件用预构建或源码引用
  • 配置类包(eslint、tsconfig)走源码引用
  • SDK 或公共能力包走可发布流程

如何初始化一个靠谱的 Monorepo

最好的学习方法就是模仿。可以参考 Turborepo Getting Started 页面。提供的示例

例如,Kitchen Sink中的 @repo/ui 采用了预构建包的方式。如果你想使用预构建包,可以重点查看 apps/admin 是如何消费它的。示例中还展示了 eslint-config 和 tsconfig 这类公共配置包的使用。不同包通过继承 base 配置,既能保证代码风格一致,也能针对不同项目做细化适配(比如规则微调或插件加载)。

如果你更倾向于使用源码引用包,可以参考Vite + React 示例。这个例子里的 @repo/ui 采用的就是源码引用包。

另外,Turborepo 还有一个面向 AI 的 best-practices/RULE.md,也可以读一读:链接

因为 Turborepo 本身就是 Monorepo 的任务编排工具,所以它的文档和示例质量都很高,值得反复参考。

共享 tsconfig.json:把配置做成一个包

在 monorepo 中创建一个共享的 tsconfig 配置包(比如放在 packages/typescript-config 里)是一个常见的做法:把一些通用的 TypeScript 配置写在基础配置(base.json)里,然后让各个项目(比如 Next.js 应用、库项目等)通过 extends 引用这个基础配置。这样整个仓库能有一致的 TS 设置。

packages/typescript-config 可以声明一个 package.json

{
  "name": "@repo/typescript-config"
}

关于 tsconfig references

在 Monorepo 中,很多人都会接触到 TypeScript 的 references 配置。Turborepo 官方建议,大多数情况下不需要使用 TypeScript 项目引用。

参考: You likely don’t need TypeScript Project References

它会引入额外的配置和缓存层,这可能会在使用 Turborepo 时带来问题,且很少能带来实际的好处。

具体来说:

  • 额外的配置:使用 TypeScript 项目引用时,你需要在不同的项目之间配置相应的 tsconfig.json 文件,这增加了配置的复杂度。

  • 额外的缓存层:TypeScript 项目引用为每个项目生成独立的构建输出,需要将缓存目录配置到.gitignore中,turbo.json中。

但在某些特定场景下,如 Hono RPC 的前后端类型联动时,项目引用非常必要。 参考:

如果你使用了源码引用包,建议统一关键编译选项(如 modulemoduleResolution),避免跨包解析不一致。

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "Bundler"
  }
}

references 示例:

{
  "references": [{ "path": "../packages/utils/tsconfig.json" }]
}

关于 package imports(子路径导入)

在编写子包代码时,可以在 tsconfig 中使用 compilerOptions.paths 来创建别名。然而,这个别名只在当前 TypeScript 配置上下文中有效,不会被其他包自动读取。

如果你使用 TypeScript 5.4+,推荐使用 Node.js 的子路径导入(imports)来替代 TypeScript 的路径别名,在 package.json 内编写:

{
  "imports": {
    "#*": "./src/*"
  }
}

那源码中可以这样引用自己的文件:

import { MY_STRING } from "#utils.ts"; // Uses .ts extension
export const Button = () => {
  return <button>{MY_STRING}</button>;
};

通过这种方式,可以在模块内部使用子路径导入,不会受 TypeScript 配置的限制。这里的imports 主要解决包内部如何引用,exports 主要解决包对外暴露什么。也就是说,跨包消费还是走包名和 exports,而不是把 imports 当成跨包 alias。

这种模式下要注意导入路径和产物格式保持一致(例如编译包需要使用 .js 后缀)。

关于跨包“跳转到定义”的实现

在 Monorepo 项目中,多个包通常是彼此依赖的。如果你希望在 IDE(如 VSCode)中通过“跳转到定义”功能,在不同包之间轻松导航(例如,从 ui 包跳转到 utils 包中的代码),需要进行一些配置,以确保不同包之间的 TypeScript 类型信息能够正确链接和识别。

对于预构建包,当包已经编译后,跳转到定义的功能通常不会直接跳转到源代码。例如,点击一个 A.js 的导出,编辑器将跳转到 dist 文件夹中的生成代码,而不是源代码。为了确保跳转功能正常工作,需要在 TypeScript 配置文件中启用 declarationdeclarationMap 选项。这样生成的 .d.ts(类型声明文件)和 .d.ts.map(源映射文件)就能帮助编辑器找到原始的 TypeScript 源代码。

配置示例:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true
  }
}

我个人对这个问题还有另一种替代方法,可以通过配置 allowImportingTsExtensionsrewriteRelativeImportExtensions 来解决,同时改成源码引用包。启用这两个选项后,编辑器会识别并允许在代码中显式地使用 .ts 扩展名进行模块导入,这样跳转功能会直接指向原始的 TypeScript 源代码。代码里原有的import ’./A.js' 也可以改成import ’./A.ts'了。

相关配置如下:

{
  "compilerOptions": {
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

配置项解释:

  • allowImportingTsExtensions:此配置项允许你在导入模块时显式使用 .ts 扩展名。默认情况下,TypeScript 会自动忽略文件扩展名,但启用此选项后,你可以在 import 语句中明确指定 .ts 扩展名。

  • rewriteRelativeImportExtensions:此配置项使得 TypeScript 在生成 JavaScript 代码时,会自动将相对路径导入的 .ts.tsx 扩展名重写为 .js 扩展名。这样,在 TypeScript 代码中使用 .ts 扩展名导入文件时,最终生成的 JavaScript 代码会使用 .js 扩展名,从而确保路径的兼容性。

通过这些配置,开发者可以更方便地在不同包之间进行跳转,提升开发效率。

用Turborepo 管理Package Graph 和 Task Graph

在一个大型项目中,或者跨语言项目中,可能会有很多命令。不同的包可能有自己的build命令,dev命令,lint命令,test命令,类型检查命令等等。这些包之间,可能还有依赖关系。一个命令以另一个命令完成为前提,而Turborepo可以很好的完成,这一节我们展示如何用 Turborepo 进行管理。

Package Graph(包图)

Turborepo 自动从你的 Monorepo 结构和各个子包的 package.json 里找出来的依赖关系图。比如你有一个 apps/web 应用,它依赖两个库 packages/uipackages/utils,Turborepo 就会把这些关联“连成线”,构成一个图,形成所有包之间的依赖网络。这个图是 Task Graph 的基础。

Task Graph(任务图)

任务图是 Turborepo 通过你的 turbo.json 配置和上面那个 Package Graph 从你的任务定义里构建出来的一个 有向无环图。节点(node)是任务(比如 build、lint、test),边(edge)表示任务之间的依赖关系——也就是 “这个任务要等另一个任务跑完才能运行”。

如果一个任务(比如 build)在 turbo.json 里写了 dependsOn: ["^build"],这就表示“在当前包的 build 任务之前,先跑掉所有它依赖的包的 build 任务”。这种依赖关系会被表示成一条从依赖任务指向当前任务的边。

例如,执行apps/web 应用的build命令之前,会先运行 packages/uipackages/utilsbuild命令。Turborepo 还有自己的缓存策略,通过指定任务的inputsoutputs,它可以观察文件是否改动,如果没有改动,就可以直接跳过这个步骤。同时,这两个任务还可以最大程度地并行化执行,提升构建效率。

此外,任务还有分类,如持续任务,可以将任务声明为"persistent": true。一些持续任务可能还需要另一个任务始终同时运行,如后端服务器,亦或者是路由库的router-cli,通过with字段可以设置自动启动。一些任务即使非TS语言仓库,也支持加入到Turborepo的任务流中。

Turborepo还支持使用tui在一个终端内同时查看所有日志并与任务进行交互。

Turborepo 能够提高构建与任务运行的效率,通过并行执行、缓存命中等优化手段加速你的 Monorepo 工作流。更多相关内容,请参考Crafting your repository - Configuring tasks

Catalog 目录协议

在 Monorepo 中,使用相同的依赖项版本非常常见。通过 pnpm-workspace.yaml 中的 Catalog 协议,我们可以减少依赖的重复并保持一致性:

  • 维护唯一版本 - 我们通常希望在工作空间中共同的依赖项版本一致。 Catalog 让工作区内共同依赖项的版本更容易维护。 重复的依赖关系可能会在运行时冲突并导致错误。 当使用打包器时,不同版本的重复依赖项也会增大项目体积。
  • 易于更新 — 升级或者更新依赖项版本时,只需编辑 pnpm-workspace.yaml 中的目录,而不需要更改所有用到该依赖项的 package.json 文件。
  • 减少合并冲突 — 由于在升级依赖项时不需要编辑 package.json 文件,所以这些依赖项版本更新时就不会发生 git 冲突。

如果你使用的pnpm,可以参考这个文档,如果是bun,则可以参考这个文档

以 pnpm 管理的 workspace 为例,它是这么使用的:

pnpm-workspace.yaml 中定义:

packages:
  - packages/*

# 定义目录和依赖版本号
catalog:
  react: ^18.3.1
  redux: ^5.0.1
{
  "name": "@example/app",
  "dependencies": {
    "react": "catalog:",
    "redux": "catalog:"
  }
}

Enginue 和包管理器配置

在 Monorepo 中使用合适的包管理器配置是至关重要的。尤其是在使用 pnpm 时,可以指定运行的 Node 版本以及 pnpm 的版本,确保包管理器和 Node.js 的版本一致,避免版本不兼容的问题。

{
  "engines": {
    "node": ">=10",
    "pnpm": ">=9"
  },
  "packageManager": "pnpm@9.3.0"
}

在本地开发时, 如果其版本与 engines 字段中指定的版本不匹配,pnpm 将始终失败并报错。

与之相对应的,还有一个pnpm-workspace.yaml的配置字段nodeVersion,当同时设置了 engine-strict=true 时,npm 会在安装包时检查你的 Node.js 版本是否大于或等于设置的版本范围(应当填精确的语义化版本号),如果不符合,安装会被拒绝。例如,当开发公共包时,设置这个选项可以保证不安装不支持特定node版本的依赖。参见链接

nodeVersion: 22.22.0
engineStrict: true

还有一个就是如果你使用了nvm配置,有时候项目根部会有一个.nvmrc文件来指定版本,这样在该目录下唤起Node时,会自动启动相应版本的Node。 例如可以设置useNodeVersion: 16.16.0。pnpm 将自动安装指定的 Node.js 版本,并使用它来运行pnpm run命令pnpm node。参见链接

Monorepo 的 hoist

Hoisting(提升)是指在安装依赖时,某些依赖会被提升到 node_modules 的顶层(根目录)。这种行为确保了在整个项目中可以共享某些常用的依赖包,而不是每个子包都单独安装一份。这有助于避免重复安装相同版本的依赖,减少磁盘空间的占用。

在 npm 和 yarn 中,依赖项的 hoisting 行为通常是自动的。当你安装依赖时,它们会根据包的依赖关系被扁平化,并被提升到 node_modules 根目录中。在 pnpm 中,依赖不会像在 npm 或 yarn 中那样自动扁平化,而是根据每个包的依赖结构创建嵌套的 node_modules 目录。

转存失败,建议直接上传图片文件

默认情况下,pnpm 创建一个半严格的 node_modules,所有依赖项都会被提升到 node_modules/.pnpm/node_modules。这使得 node_modules 中的所有包都可以访问未列出的依赖项,而 node_modules 之外的模块不行。通过这种布局,大多数的包都可以正常工作。

但是也会有一些不能正常工作例外,但你可以通过配置来控制。这种情况下,可能需要设置publicHoistPattern属性。命中的模块,会安装到根模块目录node_modules中。例如,之前版本的pnpm的默认配置['types', 'eslint', '@prettier/plugin-*', 'prettier-plugin-'],项目如果依赖了 eslintbabel,可以看到根模块目录中如下所示。一般来说,我们不需要关心这个,如果需要配置,依赖的文档会讲这些。

> tree node_modules -L 1
node_modules
  ├── @babel
  ├── @eslint
  ├── @types
  ├── @typescript-eslint
  ├── eslint
  ├── eslint-config-ali
  ├── eslint-import-resolver-node
  ├── eslint-module-utils
  ├── eslint-plugin-import
  ├── eslint-plugin-jsx-plus

关于 hoist 的更多知识,可以参考这个文章:A diagram to show how pnpm works

一些值得设置的 npmrc 配置或者 pnpm-workspace 设置

npmrcpnpm-workspace 中设置适当的配置项,能有效提高项目管理效率。

npmrc

  • registry:指定 npm 使用的默认注册表 URL。

  • save-exact:确保依赖项以精确版本安装,而不是使用版本范围,例如^1.2.3。

pnpm

  • prefer-frozen-lockfile:强制使用锁定文件中的依赖版本。如果设置为 true,即使 package.json 中的依赖有更新,也会优先使用锁定文件(pnpm-lock.yaml)中的版本,避免自动升级。

  • overrides:强制指定某些依赖包的版本,无论这些包在其他依赖包中是否有版本冲突。例如,假设你有两个依赖包 AB,它们依赖于同一个包 C,但它们的版本不同。通过 overrides,你可以强制这两个包都使用 C 的同一版本。


参考资料

  1. TypeScript 5.4: Auto-import support for subpath imports
  2. Turborepo TypeScript 指南
  3. 设置(pnpm-workspace.yaml)
  4. A diagram to show how pnpm works 5.Crafting your repository - Configuring tasks

Isolation Pattern(隔离模式)在前端与 Core 之间加一道“加密网关”,拦截与校验所有 IPC

一、Isolation Pattern 是什么

Isolation Pattern 的核心目标是:用 JavaScript 在 WebView 内部实现一层安全拦截器,把前端到 Core 的 IPC 通道变成“必须先过隔离层”的受控通道。

在这个模式下,前端应用并不是直接把消息交给 Core,而是:

  1. 先交给隔离应用(Isolation application)
  2. 隔离应用运行 hook,对消息做检查/改写
  3. 隔离应用将消息用 AES-GCM 加密(密钥每次启动动态生成)
  4. 加密后的消息再被转交给 Core 解密并处理

隔离应用本质上就是一段 被注入的安全 JS,它是 Isolation Pattern 的“执行载体”。

二、为什么要 Isolation:解决“前端不可信内容”的威胁模型

Isolation Pattern 诞生的动机非常现实:现代前端项目往往依赖庞大且深层嵌套的依赖树,风险来源包括但不限于:

  1. 构建期工具链(几十到上百个依赖)
  2. 运行时代码(打包进产物的依赖同样可能很多)
  3. 外部内容或第三方脚本(如果你加载了外部资源,风险更高)

这些都可能导致“前端执行环境不完全可信”。Isolation 的价值就在于:即使前端被污染,仍然能在进入 Core(拥有完整系统权限)之前做集中拦截与约束

三、什么时候用:官方强烈建议“能用就用”

Isolation Pattern 的一个特点是:它拦截的是“所有 IPC 消息”,因此几乎在任何应用里都能启用。

更进一步,官方建议当你使用外部 Tauri API(比如文件系统、HTTP、shell、窗口管理等)时,最好同时做更严格的“锁定(lockdown)”策略,并利用隔离应用进行输入验证,例如:

  1. 文件读写:限制路径必须落在应用允许的目录内,禁止越权访问
  2. HTTP 请求:限制 Origin、URL 白名单、Header 约束等
  3. 事件(Events):即使是“常开 API”,也能在隔离层拦截与验证,因为事件触发可能导致 Rust 执行敏感动作

四、怎么工作:sandbox iframe + AES-GCM 加密通道

Isolation Pattern 的机制可以概括为“三件事”:

  1. 隔离运行环境
    利用 <iframe sandbox> 的沙箱能力,让隔离应用在更受限、更可控的环境中运行,减少被主前端影响的概率。
  2. 强制消息经由隔离层
    Tauri 在页面加载时强制所有 IPC 调用先路由到隔离应用。
  3. 加密与动态密钥
    隔离应用使用浏览器 SubtleCrypto 对消息进行 AES-GCM 加密,再传回主前端并转交给 Core;Core 解密后像正常 IPC 一样处理。密钥每次应用启动都会重新生成,避免攻击者复用旧密钥对消息做离线篡改。

IPC 消息的大致流程(按步骤理解)

  1. IPC handler 收到前端消息
  2. IPC handler → 隔离应用
  3. 隔离应用 hook 执行(可修改/校验 payload)
  4. 隔离应用使用运行时生成密钥 AES-GCM 加密
  5. 隔离应用 → IPC handler(传回加密消息)
  6. IPC handler → Tauri Core(Core 解密并处理)

五、性能影响:有开销,但通常可忽略

Isolation 相比默认模式会增加:

  1. 每条 IPC 的加解密开销(AES-GCM)
  2. 启动时生成一次安全随机密钥(依赖系统熵池)

大多数应用不会明显感知这点开销,因为 AES-GCM 很快;真正需要关注的是“极致性能敏感应用”,它们往往依赖更少、攻击面更小,也更容易接受其它模式。

如果你在无头环境做 WebDriver 集成测试,可能会遇到熵不足导致启动慢的问题;这时候需要在系统层补足熵源(例如 Linux 环境的相关服务/机制)。

六、限制与坑点:Windows 的 sandbox iframe 外部文件加载限制

一个最重要的限制来自平台不一致:Windows 上 sandbox iframe 对外部文件加载存在问题。因此 Tauri 在构建时做了“脚本内联(script inlining)”步骤:

  • 传统 <script src="index.js"></script> 这种相对路径脚本会被内联处理,从而正常工作
  • 但较新的机制例如 ES Modulestype="module" / import ...)在隔离应用里可能无法正常加载

结论是:隔离应用尽量用“老实的、最简单的脚本加载方式”,别追求花哨打包玩法。

七、官方建议:隔离应用要极简、低依赖

Isolation 的目标是抵御“开发期/供应链威胁”,所以隔离应用本身也要避免成为新的供应链入口。

推荐做法:

  1. 逻辑尽量少:只做校验、白名单、简单转换
  2. 依赖尽量少:最好零依赖或极少依赖
  3. 构建步骤尽量少:减少额外工具链带来的风险

八、手把手示例:创建最小 Isolation Application

假设你主前端的 frontendDist../dist,隔离应用输出到 ../dist-isolation

1)隔离应用:../dist-isolation/index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Isolation Secure Script</title>
  </head>
  <body>
    <script src="index.js"></script>
  </body>
</html>

2)隔离应用:../dist-isolation/index.js

window.__TAURI_ISOLATION_HOOK__ = (payload) => {
  // 不做校验,仅打印并原样返回
  console.log('hook', payload);
  return payload;
};

这个 hook 是你真正写安全逻辑的地方:你可以在这里做参数校验、路径约束、Origin 校验、命令白名单等。

九、启用 Isolation Pattern:tauri.conf.json 配置

{
  "build": {
    "frontendDist": "../dist"
  },
  "app": {
    "security": {
      "pattern": {
        "use": "isolation",
        "options": {
          "dir": "../dist-isolation"
        }
      }
    }
  }
}

含义非常直观:

  1. 主前端资源在 ../dist
  2. security pattern 使用 isolation
  3. 隔离应用目录在 ../dist-isolation
❌