普通视图

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

React 性能优化完全指南 2026

2026年3月2日 09:16

原文:A complete guide to React performance optimization

翻译:TUARAN

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

如今的用户默认就期待应用“又快又顺”。性能不再只是“锦上添花”,它是真正的产品优势,会直接影响留存、转化和收入。

难点在于:排查性能问题常常让人崩溃,因为一个应用变慢的原因实在太多了。

在这份指南中,我会分享一个循序渐进的框架:从分析 bundle 开始,一路优化到服务端渲染。按这四个阶段走,你可以在不牺牲代码质量和开发体验的前提下,把 LCP 从 28 秒降到约 1 秒(超过 93% 的提升!)。

我们会用一个“视频播放器应用”作为示例,按阶段逐步提升性能。你可以在这里获取示例代码仓库:github.com/shrutikapoo… 。这篇指南也有视频版。

🚀 订阅 The Replay Newsletter(原文站内推荐)

The Replay 是面向开发者与工程负责人(dev + engineering leaders)的每周通讯。

每周一期,精选你需要关注的前端开发讨论、正在涌现的 AI 工具、以及现代软件开发的现状。


建立基线(Establish baseline)

在改任何东西之前,我们得先知道现状。

先在 Chrome DevTools → Performance 里拿到基线数据。

  • 把网络限速设为 Slow 4G
  • 关闭缓存(Disable cache)

这样结果才能更接近真实用户环境。

录制一次应用里的“正常用户流程”,观察几个关键指标:

  • First Contentful Paint(FCP)
  • Largest Contentful Paint(LCP)
  • Time to Interactive(TTI)

这些数字能让你很快看出“慢”到底慢在哪。下面是我们起步时的结果:


阶段 1:分析并优化 bundle(Phase 1: Analyze and optimize the bundle)

优化的第一步,是搞清楚你到底给用户发了什么。

在动代码之前,先看 bundle,从中找出最值得优先优化的地方。

  1. 给构建加一个 bundle analyzer 来可视化包体:
  • Webpackwebpack-bundle-analyzer
  • Vitevite-bundle-analyzerrollup-plugin-visualizer
  1. analyzer 会给你一个交互式的 treemap,告诉你哪些包/文件占了最多空间。

你经常会发现:某个“大依赖”(通常是第三方库)吃掉了很大一块体积——这能立刻帮你明确“先优化谁”。

从这张图可以看出:一些 node modules 占了很大一部分体积,hero 图片也不小。好消息是,我们的 src 目录占比很小。

优化构建(Optimizing build)

  • 确认生产环境启用了 JS 和 CSS 的压缩(minification)。 现代构建工具大多在 production 模式默认开启,但你最好确认“真的有生效”。压缩会移除空白、缩短变量名,并做其他转换,显著减少文件体积。

  • 开启代码分割(code splitting),按路由/功能把 bundle 拆成更小的 chunk。

    与其把所有代码打成一个巨大的 JS 文件,不如只给当前页面发必需的代码,其余按需加载。

    这个项目使用 TanStack Router,所以我们会按路由拆分。这样后续就可以很容易对不常访问的路由做懒加载导入。

原文示例(节选,按可读性整理):

// vite.config.ts
export default defineConfig({
build: {
outDir: "dist",
emptyOutDir: true,
sourcemap: true,
minify: true,
cssMinify: true,
terserOptions: {
compress: false,
mangle: false,
},
},
// ...
// tanstackRouter({
//   target: 'react',
//   autoCodeSplitting: false,
// }),
});

组件懒加载(Lazy load components)

当我们放大 bundle analyzer 里 src/components 的区域,可能会发现:某些组件占了不少体积。

这时就可以通过懒加载来优化:确保它们只在用户真的导航到需要它们的页面/路径时才会被 import。

// MovieList.tsx
import { lazy } from "react";

const MovieCard = lazy(() => import("@/components/MovieCard"));

移除未使用依赖(Removing unused dependencies)

  • 运行 npx depcheck 找出 package.json 里未被实际使用的 node modules。

    depcheck 会扫描代码库并报告“没有在任何地方 import 的包”,你就可以安全移除它们,从而减少 bundle 体积。

再次测量(Measure again)

为了确认这些改动确实带来收益,我们必须再测一次。

通过 npm run build 重新打包:

影响(Impact):

仅仅通过代码分割、移除不必要的 node modules、压缩文件,我们就把 bundle 从 1.71MB 降到了 890KB!

LCP 也从 28.10 秒降到了 21.56 秒:

接下来进入更“好玩”的部分:优化 React 组件。


阶段 2:优化 React 代码(Phase 2: Optimizing React code)

在 React Compiler 出现之前,你必须手工找出性能瓶颈,然后通过 useMemo / useCallback 等手段做记忆化(memoization)来优化组件。

但现代 React 开发已经有了 React Compiler,它可以自动处理大量性能优化。

除此之外,新的性能监控工具(例如自定义的 React Performance tracks)也让“到底发生了什么”更透明,你不必再靠猜测来判断哪些组件渲染慢。

在开始优化之前,我们先看一下当前可用的工具。

1) React 19 Performance tracks

React 19 引入了自定义的 Performance tracks。它把性能分析能力直接集成进 Chrome DevTools 的 Performance 面板,让你能定位真实的渲染瓶颈,而不是凭感觉猜哪个组件慢。

它会展示每个组件在 React 生命周期四个阶段中分别花了多少时间:

  • blocking
  • transition
  • suspense
  • idling

trace(追踪)能把“长任务(long tasks)”关联回具体组件的工作和 hook 逻辑,从而快速隔离:昂贵的渲染路径、不必要的重复计算、以及可避免的重复渲染。

来源:react.dev/reference/d…

2) React Compiler

React Compiler 改变了我们今天看待记忆化的方式。

在它出现之前,开发者往往需要手动:

  • React.memo 包裹组件
  • useMemo / useCallback 包裹回调或计算

来避免不必要的重新渲染。

这种方式容易出错,而且需要你花大量精力判断“到底哪些组件需要记忆化”。即便手工做了记忆化,也很容易漏掉真正慢的部分。

React Compiler 会作为 Babel 插件接入构建流程,自动分析组件,并基于 Rules of React 施加记忆化。

它理解 React 的渲染行为,能做出比手工优化更“聪明”的决定,在很多情况下甚至能超过人肉优化。

要开始使用它,先安装 compiler 并把它加到 Babel 配置中:

npm install -D babel-plugin-react-compiler@latest

然后更新 Vite 配置(原文示例):

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [
react({
babel: {
plugins: ["babel-plugin-react-compiler"],
},
}),
],
});

现在,当你在 React Profiler 里打开组件,会注意到:被 compiler 自动记忆化的组件旁边会出现一个 ✨ 标记:

3) React Profiler

虽然它出现很久了,但仍然非常有用:你可以用它理解组件重渲染的次数,以及到底哪些组件在重渲染。

本文也会把它与 React Compiler、Performance tracks 一起用,来找到真正慢的组件。

测量(Measure)

使用 React Profiler 时,我们测量用户最常走的一条 UX 路径。本文测量的流程是:

  1. 点击一张电影卡片,打开电影详情
  2. 播放电影预告片
  3. 返回首页

你可以看到右上角:在这条 UX 流程中,应用重渲染了 16 次

最高的那根柱子来自 Movie List 组件,渲染耗时 25ms。

这让我们更清楚:哪个组件最慢、以及它的重渲染频率最高。

改进点(Improvements)

1) 让 React Compiler 负责记忆化

有了 React Compiler,你不必手动到处加 useMemo / useCallback

它可以自动减少不必要的重渲染和重复计算,你就能把注意力放在“真正需要改的代码问题”上。

2) 清理 useEffect

useEffect 很容易导致不必要的重渲染。

能不写就尽量不写;必须写时,确保 effect 正确清理,并且不会造成无限的 state update。

作者在另一篇文章里更深入讨论了最常见的 useEffect 错误:blog.logrocket.com/15-common-u…

3) 清理函数定义

一个常见错误是:在组件函数体里定义一些“其实不属于这里”的函数(比如纯工具函数)。

问题在于:每次组件渲染,这些函数都会被重新创建——即便它们的实现根本不变。这会给 JS 引擎带来不必要的工作。

把工具函数挪到组件外,或放到单独的工具文件里:

const formatRuntime = (minutes: number) => {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours}h ${mins}m`;
};

4) 懒加载组件

用户一开始看不到的大组件,是懒加载的绝佳候选。

像视频播放器、图表、富文本编辑器这类组件,会让初始 bundle 变大,即便大多数用户从来不会用到它们。

React 通过 React.lazy + Suspense 让这件事很容易:

  • React.lazy() 替代普通 import,让组件只在需要时加载
  • <Suspense> 包起来,在加载期间展示 fallback UI(比如 spinner / skeleton)

它和“按路由分割”的 code splitting 配合尤其好:只有用户访问某个页面时才加载对应代码。

import { lazy, Suspense } from "react";

const MovieCard = lazy(() => import("@/components/MovieCard"));

5) 列表虚拟化(Virtualized lists)

渲染包含大量 DOM 节点的长列表,是非常常见的性能问题。

多数用户甚至不会把列表滚到最底部,你却为看不见的内容做了很多渲染工作。

列表虚拟化的思路是:只渲染屏幕可见的部分(再加一点 buffer)。用户滚动时,元素在 DOM 中被动态添加/移除——列表看起来完整,但性能更好。

react-window(更轻量)或 react-virtualized(功能更丰富)这样的库,可以很容易实现它。

影响(Impact):

你会看到:应用的重渲染次数下降了,峰值也变低了,最大一次渲染为 13.1ms:

LCP 也下降了 2 秒。虽然这不是一个巨大的 LCP 改进,但仍然令人鼓舞——因为它说明我们正在朝正确方向前进。


阶段 3:把工作移到服务端(SSR)(Phase 3: Moving to the server)

客户端渲染(CSR) 往往会更慢,因为用户经常会在浏览器下载并执行 JS、再去请求数据期间,看到空白屏或 loading spinner。

这种延迟是 LCP 不佳的主要原因之一,会导致“元素渲染延迟(element render delay)”。

服务端渲染(SSR)通过在服务端先把数据取好、生成 HTML,再把页面发给浏览器来解决这个问题。

用户能立刻看到真实内容,而 JS 在后台加载并 hydration。

采用框架(Adopting a framework)

你当然可以自己搭 SSR,但像 Next.js、Remix、或 TanStack Start 这样的框架会让事情更容易,也更适合生产环境。

TanStack Start 还支持 streaming SSR:服务端可以在生成 HTML 的同时就开始往浏览器发送,而不是等整页渲染完再一次性返回。

迁移到框架通常意味着要改路由与数据获取方式,但性能收益巨大。

你不只是在微调客户端代码,而是在改变页面“何时、在哪里”渲染:数据在组件渲染前就已经在服务端准备好,从而显著降低 LCP。

Server functions

在 TanStack Start 中,你可以通过 server function 在服务端获取数据。

本文把原本在客户端 useEffect 中的数据请求迁到服务端,写成 server function。

原文“前后对比”代码(按可读性整理):

// Before: data-fetching in useEffect
useEffect(() => {
async function fetchPopularMovies() {
const token = import.meta.env.VITE_TMDB_AUTH_TOKEN;

if (!token) {
setError("Missing TMDB_AUTH_TOKEN environment variable");
setLoading(false);
return;
}

setLoading(true);
setError(null);

try {
const response = await fetch(API_URL, {
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
});

if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`);
}

const data = (await response.json()) as TMDBResponse;
setMovies(data.results);
} catch (error) {
setError((error as Error).message);
} finally {
setLoading(false);
}
}

fetchPopularMovies();
}, []);
// After: Data-fetching in TanStack Start Server Function
export const getMovies = createServerFn({
method: "GET",
}).handler(async () => {
try {
const response = await fetch(`${API_URL}/popular`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
});

if (!response.ok) {
throw new Error(`Failed to fetch movies: ${response.statusText}`);
}

const movies = await response.json();
return { movies };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
throw new Error(`Movies fetch failed: ${errorMessage}`);
}
});

影响(Impact)

LCP 下降到了 13.43s


阶段 4:静态资源与图片优化(Phase 4: Asset and image optimization)

图片往往是拖慢 LCP 的最大因素。

下面是一些常用的优化图片/视频资源交付的技巧。

使用 CDN(CDN usage)

把本地的大资源(例如 hero 背景)搬到 CDN(如 Cloudinary、Cloudflare),减少你自己应用服务器的压力。

很多 CDN 还能自动做图片优化:对支持的浏览器下发 WebP/AVIF,并为老浏览器回退到 JPEG/PNG。

把大资源放到 CDN 也会减少应用服务器负载,并降低 bundle 体积。

标记优先级(Priority tagging)

并不是所有图片都同等重要。

浏览器无法自动判断:哪些图片对首屏至关重要,哪些在首屏之外或某些 Tab 里用户可能永远不会打开。

你需要明确告诉浏览器:

  • 对首屏关键图片使用 fetchpriority="high"
  • 对其余图片使用 loading="lazy"

原文示例(按可读性整理):

<!-- Hero banner 是最高优先级,因此 fetchPriority=high -->
<img
src="https://res.cloudinary.com/dubc3wnbv/image/upload/v1760360925/hero-background_ksbmpq.jpg"
fetchpriority="high"
alt=""
/>

<!-- Movie Card 图片懒加载 -->
<img
src={movie?.poster_path ? TMDB_IMAGES_ASSET_URL + movie?.poster_path : "/placeholder.svg"}
alt={movie?.title}
loading="lazy"
/>

预加载关键资源(Preloading critical resources)

现代框架(比如 TanStack Router)可以自动预加载路由。

例如用户把鼠标悬停在链接上时,就可以提前加载下一页的代码和数据,等用户真的点下去时,导航会显得“瞬间完成”。

// router.tsx
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
});

你也可以预加载重要的 CSS 和字体,让它们立即开始下载,而不是等到之后才被浏览器“发现”。

这样可以减少 layout shift,并避免未样式化内容闪烁(FOUC)。

// __root.tsx
links: [
{ rel: "preload", href: appCss, as: "style" },
];

Next.js 企业级落地

2026年3月2日 09:12

原文:Next.js at Enterprise Level

翻译:TUARAN

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

Next.js 开箱即用,体验通常很顺手——直到流量和复杂度同时上来。本文讨论的是:在不推倒重写的前提下,如何把 Next.js 推到企业级规模,从缓存与 CDN,到横向扩容、API Gateway,再到更上层的架构策略。

Next.js 是一个构建在 React 之上的全栈框架,支持服务端渲染(SSR)与静态站点生成(SSG)。它允许你在服务端构建 UI,同时直接访问数据库、文件系统等服务端资源;并可通过 Server Actions 简化前后端通信。

框架内置了较完善的缓存机制,且配置成本不高,因此对中小型应用来说,默认配置通常就能获得不错效果。

但对于大型企业应用,真正进入生产后往往需要更深的架构理解。要在规模化场景下保持效率与稳健,开发者必须超越默认设置,把一系列高级优化手段组合起来使用。

(原文说明:本文内容由人工撰写,仅由 AI 进行校对。)

问题

在企业级场景里,可扩展性往往是首要指标。默认配置足以快速起步,但随着流量增长,这些默认策略会逐渐演变为瓶颈,并带来:

  • 性能不稳定
  • 资源利用效率低
  • 运维成本上升

要在规模化后依然保持高效和稳定,就需要把系统设计模式、性能优化手段与 Next.js 机制结合起来。

SLA / SLO 与监控体系

定义服务质量:SLA vs SLO

在上线前,应该先明确系统的“非功能性需求”(例如性能、可用性、延迟、错误率等)。文章把这些目标分成两类:

  1. 定义
  • SLA(Service Level Agreement):对用户/客户的正式服务协议。达不到 SLA 可能带来赔偿或法律风险。
  • SLO(Service Level Objective):内部的服务目标,通常比 SLA 更严格,用于留出“缓冲区”。
  1. 为什么要尽早定清楚

这些指标决定了系统是否“可用”。目标通常来自:

  • 行业标准:例如 Core Web Vitals。页面加载超过 2 秒可能影响 SEO,进而影响流量和收入。
  • 竞争基准:至少要对齐竞争对手体验。
  • 安全与关键性:在高风险系统里(例如自动驾驶),实时准确是底线。
  1. 工程与业务对齐

业务方确定目标,但工程师需要帮助解释技术取舍与每个指标的重要性。越早定清楚,越能更合理地编排架构组件与资源。

如何验证:监控 + 压测

  • 监控(Monitoring):在基础设施层面持续采集非功能指标,实时知道当前实现是否达标。
  • 压测(Load Testing):上线前用虚拟用户与合成流量模拟真实压力,尽量把问题暴露在生产之前。

Next.js 的生命周期:动态渲染 vs 静态托管

理解 Next.js 内部“请求进来到底发生了什么”对于扩容很关键:当你从单实例变成几十个副本时,清楚每一步才能预测瓶颈。

1) 动态请求生命周期(SSR / ISR)

用户请求一个运行中的 Next.js 页面时,服务端大致会经历:

  • 渲染:用内部 React 引擎在服务端渲染页面。
  • 响应下发:服务端返回包含:
    • HTML:用于浏览器立即显示
    • RSC Payload(React Server Components):用于客户端 hydration
  • Hydration:浏览器用 RSC payload 把静态 HTML 变成可交互页面。

2) 静态方案(SSG)

如果应用完全不依赖服务端资源(纯静态),生命周期会大幅简化:

  • 构建时生成:构建阶段一次性生成 HTML/CSS/JS。
  • 静态托管:不需要 Node.js 常驻服务,直接由 CDN 或 Nginx/Apache 提供静态文件。
  • 扩展优势:静态站点更易扩展,因为消除了每次请求的服务端渲染 CPU 开销。

进一步:请求进来后的拦截与缓存

请求进入 Next.js 后,会先经过 proxy.ts(Next.js <15 中叫 middleware)进行鉴权等判断;通过后再路由到具体页面。

接着,Next.js 会先查内部缓存:

  • 如果存在可复用的有效缓存,则直接命中返回。
  • 如果没有,则生成响应并缓存,供后续请求复用。

文章强调:Next.js 的缓存发生在多个阶段。

构建阶段(Build time)可能会预生成页面 HTML、静态资源以及 RSC payload 文件——这就是 SSG 的本质:请求还没来,内容已经提前算好了。

运行阶段(Runtime)每个请求也会尝试命中缓存:可能命中预生成 HTML、RSC payload,或命中 fetch 结果(通常落在文件系统缓存里)。如果你额外使用 React cache 或自定义缓存,也会参与最终响应的生成。

一个关键问题是:当你部署多个相同应用实例时,每个实例的本地缓存是彼此隔离的——这会直接引出分布式缓存挑战(后文会讲)。

让 Next.js 先跑快:横向扩容前的“容易收益”

在开始复杂的横向扩容(scale out)之前,先把低成本高收益的优化做掉。

1) CDN

CDN 往往是“ROI 最大、复杂度最低”的优化:合理设置缓存头并接入 CDN,延迟可下降 30%~70%。

示例 1:在 Route Handler 中设置 Cache-Control

// /api/hello/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json(
    { message: "Hello World" },
    {
      status: 200,
      headers: {
        // CDN 缓存:缓存 1 小时,回源再验证期间允许使用旧内容
        "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=59",
      },
    }
  );
}

示例 2:为静态资源自定义缓存头

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // CDN 缓存 /public/images 下的图片 30 天
        source: "/images/:path*",
        headers: [{ key: "Cache-Control", value: "public, max-age=2592000, immutable" }],
      },
      {
        // Next.js 构建产出的 JS/CSS chunk 缓存 1 年
        source: "/_next/static/:path*",
        headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
      },
    ];
  },
};

export default nextConfig;

2) 纵向扩容(Vertical scaling)

纵向扩容依然是快速且有效的策略:通过分析应用逻辑,确定资源基线并为峰值预留 buffer,很多情况下能在“几乎不改代码”的前提下显著提升承载能力。

3) 编码最佳实践

通过工程实践提升性能,避免基础设施过度改造:

  • 渲染策略:使用异步组件与 Partial Prerendering(PPR)来更快地交付内容、改善核心指标。
  • DOM 优化:控制 DOM 规模,减少过深嵌套,提升 SSR 与客户端渲染性能。
  • 保持框架更新
    • React 19+ 引入 React Compiler(通过配置启用),可自动做 memoization。
    • Next.js 16+ 引入 use cache,让组件/Server Actions 的缓存更省力。

文中给了 PPR 的示例(表达的是“静态壳先到,动态内容流式补上”):

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;
// app/page.tsx
import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <h1>Static shell (sent immediately)</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <AsyncContent />
      </Suspense>
    </>
  );
}

async function AsyncContent() {
  const data = await fetch("/api/data").then((r) => r.json());
  return <div>{data.message}</div>;
}

横向扩容:副本与负载均衡

**横向扩容(Horizontal scaling / scale out)**指把同一个服务部署成多个副本,由负载均衡器作为入口分发流量。常见算法包括:

  • Round Robin(轮询)
  • Weighted Round Robin(加权轮询)
  • Least Connections(最少连接)
  • IP Hash(按 IP 固定路由,形成粘性会话)

横向扩容的架构挑战

1) 实现无状态(Statelessness)

要想“任意副本都能处理任意请求”,应用最好无状态:

  • 把会话等状态外置到数据库或全局缓存
  • 如果无法做到完全无状态,可通过负载均衡的 IP Hash 实现 Sticky Sessions

2) 分布式缓存问题

Next.js 默认把缓存落到本地文件系统。多实例时,每台机器都有自己的缓存,会导致:

  • 冷缓存(cold cache)问题
  • 命中率下降

解决方向是引入所有副本可共享的缓存:

  • 共享盘(不推荐,容易引入并发与扩展瓶颈)
  • Redis 集群(推荐,通过自定义 cache handler 共享缓存)

参考:Next.js 官方示例(Redis Cache Handler)

战略性扩容与自动化

领域驱动扩容:微前端(Micro-Frontends)

不一定要整体复制整个系统。你可以按 DDD(领域驱动设计)把应用拆成多个子域,按热点域独立扩容。例如电商里“商品”域可能需要更多副本,“个人资料”域则少一些。

Kubernetes 自动扩缩容

用 Kubernetes 等平台把容器化的应用按实时负载自动扩缩容:流量高峰时扩容、副本增加;低谷时回收资源,降低成本。

API Gateway

把认证、缓存等横切关注点抽到统一层,可以显著提升可维护性与可扩展性。文章把“鉴权”作为典型例子:当每个应用都在 proxy.ts 里重复做鉴权时,它很适合被抽到 API Gateway。

API Gateway 位于受保护基础设施之前,作为反向代理并负责 TLS 终止。终止 TLS 后,网关与内部服务之间可以走内网 HTTP,降低开销。

文中用 Nginx 配置展示该模式:

http {
  upstream cart_app    { server localhost:3001; }
  upstream product_app { server localhost:3002; }
  upstream profile_app { server localhost:3003; }

  server {
    listen 80;

    location = /auth-verify {
      internal;
      proxy_pass http://your-auth-api/validate;
      proxy_pass_request_body off;
      proxy_set_header Content-Length "";
    }

    location /cart {
      auth_request /auth-verify;
      proxy_pass http://cart_app;
    }

    location /product {
      auth_request /auth-verify;
      proxy_pass http://product_app;
    }

    location /profile {
      auth_request /auth-verify;
      proxy_pass http://profile_app;
    }

    error_page 401 = @error401;
    location @error401 {
      return 401 "Unauthorized - Invalid Token";
    }
  }
}

优化文件上传:从本地磁盘到对象存储

本地存储的缺点

把用户上传文件直接存到应用服务器本地盘,会带来三类风险:

  • 持久性差:硬盘故障或机器宕机可能导致永久丢失
  • 扩展性差:大文件传输会吃带宽和 CPU,拖慢处理其他请求
  • 基础设施压力:后续再把文件搬到长期存储,徒增内部流量与复杂度

现代方案:Blob Storage + Signed URL

企业级常见做法是使用 S3 / GCS / Azure Blob 等对象存储。关键在于 Signed URL 直传

  • 应用生成临时签名 URL
  • 浏览器绕过应用服务器,直接把文件上传到对象存储

这样可以把大流量文件传输从你的应用节点“卸载”出去,提高扩展能力并降低延迟。

事件驱动架构:应对高并发交互

很多用户行为会触发一串内部调用。例如“加入购物车”可能需要:购物车服务、埋点分析、日志、库存系统(SAP)……当并发很高时,同步串行调用会让系统迅速变慢甚至崩溃。

文章建议用 事件驱动架构(EDA) 解耦:

  • 主流程只负责发出事件并快速返回
  • 下游任务异步处理
  • Kafka 等事件总线可以做缓冲:即便某个老系统很慢,前台请求也不至于被拖垮
  • 下游服务短暂故障时,主流程仍可成功,待恢复后再补处理

通信协议优化:HTTP/2 与 gRPC

HTTP/2 的优势

HTTP/2 主要通过:多路复用、二进制分帧、HPACK 头压缩 等特性,解决 HTTP/1.1 的连接与性能瓶颈。

Nginx 启用 HTTP/2 的配置示例:

server {
  listen 443 ssl http2;
  server_name api.example.com;
}

内部服务通信:gRPC vs REST

对外 API 常用 REST,但内部服务间通信 gRPC 往往更合适:

  • 默认基于 HTTP/2
  • Protobuf 二进制比 JSON 更小、更省 CPU
  • 强类型契约降低协作错误

总结

企业级 Next.js 的关键,是在默认能力之上补齐工程化与架构能力:

  • 定清 SLA / SLO 并建立监控与压测
  • 理解请求生命周期与缓存行为
  • 先做 CDN、缓存头、编码实践等“低成本高收益”优化
  • 进入横向扩容后,用共享缓存(例如 Redis)解决多副本命中率问题
  • 把鉴权等横切逻辑上移到 API Gateway
  • 文件上传用对象存储直传
  • 高吞吐交互用事件驱动解耦
  • 合适场景引入 HTTP/2 / gRPC

文章最后的建议很务实:不需要一次性把所有手段都上齐。先从你当前最痛的瓶颈开始(例如 CDN 或缓存策略),用 SLO 衡量效果,然后迭代推进。

鸿蒙-深色模式适配

作者 Huang兄
2026年3月2日 09:12

前言

在移动应用设计与开发领域,深色模式已从可选功能升级为用户体验的核心标配。它不仅能适配夜间使用场景、降低屏幕功耗与视觉疲劳,更能彰显应用的设计质感与人性化考量,成为衡量产品成熟度的重要指标。 本文聚焦手机应用开发中的深色模式适配实践,从设计原则、色彩体系构建、代码实现逻辑等维度展开解析,结合实际开发中的常见问题与优化方案,为开发者提供一套可落地的适配思路与技术参考,助力打造兼顾视觉体验与用户需求的高品质应用。

粗暴不适配

最粗暴的方法就是不适配,强制写死为亮色(浅色)模式. 在Ability 的 onCreate 方法中

this.context
  .getApplicationContext()
  .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);

这样,即使我们引用了一些系统资源,也不会在用户开启深色模式后随着改变。

适配

颜色适配

需要将之前硬编码在组件中的色值提取到 color.json文件中。

  1. base/element/color.json定义浅色模式颜色
  2. dark/element/color.json定义深色模式同名颜色
  3. 通过$r('app.color.resource_name')引用资源

这样在切换深色模式时,系统会根据当前模式自动匹配对应目录下的颜色值。 比如,在base/element/color.json文件中

{
  "color": [
    {
      "name": "page_background_color",
      "value": "#ffffff"
    },
    {
      "name": "text_desc_color",
      "value": "#999999"
    },
    {
      "name": "text_subtitle_color",
      "value": "#666666"
    },
    {
      "name": "text_title_color",
      "value": "#333333"
    }
  ]
}

dark/element/color.json文件中

{
  "color": [
    {
      "name": "page_background_color",
      "value": "#000000"
    },
    {
      "name": "text_desc_color",
      "value": "#666666"
    },
    {
      "name": "text_subtitle_color",
      "value": "#999999"
    },
    {
      "name": "text_title_color",
      "value": "#cccccc"
    }
  ]
}

然后在代码引用

Text(
  '在 resource 文件夹下新建 dark/element/color.json文件,同时在该文件中配置和 base/element/color.json文件中相同的颜色名称和不同的颜色值',
)
  .fontSize(14)
  .fontColor($r('app.color.text_desc_color'));
Text("在页面中应用配置文件中的色值,例如$r('app.color.text_title_color')")
  .fontSize(14)
  .fontColor($r('app.color.text_desc_color'));
Text('在手机中切换颜色模式,再返回应用')
  .fontSize(14)
  .fontColor($r('app.color.text_desc_color'));

媒体图片资源适配

静态图片适配:在 base/media 和 dark/media 目录放置同名图片文件,深色模式下系统优先加载 dark 目录的图片。 对于 svg 格式的图片,我们还可以使用 Image 组件的fillColor属性根据当前的颜色模式填充不同的颜色已达到适配的目的,但对于 svg 图片来讲,这个属性不是太好用,还不如再设计一张深色模式的svg 图片来的方便。

Row() {
  Image($r('app.media.color_mode_icon')).width('50%')
  Column() {
    Image($r('app.media.color_mode_icon_svg')).height('50%').objectFit(ImageFit.Contain)
    Image($r('app.media.color_mode_icon_svg'))
      .height('50%')
      .fillColor($r('app.color.text_subtitle_color'))
      .objectFit(ImageFit.Contain)
  }.height(px2vp(1024)).width('50%').justifyContent(FlexAlign.Start)
}.width('100%').alignItems(VerticalAlign.Top)

状态栏适配

在 Ability 的onConfigurationUpdate回调中判断当前颜色模式,设置不同的状态栏颜色.


  onConfigurationUpdate(newConfig: Configuration): void {
    this.setColorMode(newConfig.colorMode == ConfigurationConstant.ColorMode.COLOR_MODE_DARK)
  }

    setColorMode(isDark: boolean) {
    if (isDark) {
      window.getLastWindow(this.context).then((win) => {
        console.error('设置状态栏为深色模式')
        win.setWindowSystemBarProperties({
          statusBarColor: "#ff0000",
          statusBarContentColor: "#000000",

        }).catch((error:BusinessError) => {
          console.error('设置状态栏为深色模式 出错 ' + error.message)
        })
      })
    } else {
      window.getLastWindow(this.context).then((win) => {
        console.error('设置状态栏为浅色模式')
        win.setWindowSystemBarProperties({
          statusBarColor: "#00ff00",
          statusBarContentColor: "#ffffff",

        }).catch((error:BusinessError) => {
          console.error('设置状态栏为浅色模式 出错 ' + error.message)
        })
      })
    }
  }

当然也可以在onWindowStageCreate回调的windowStage.loadContent方法中判断一下当前的颜色模式,来设置初始状态

windowStage.loadContent('pages/Index', (err) => {
  this.setColorMode(
    this.context.config.colorMode ==
      ConfigurationConstant.ColorMode.COLOR_MODE_DARK,
  );
});

webview适配

Web组件设置:通过 Web 组件的 .darkMode(WebDarkMode.Auto) 使网页跟随系统模式,或手动设置 .darkMode(WebDarkMode.On/Off)2。 强制深色转换:使用 .forceDarkAccess(true) 对未适配深色的网页进行色值算法转换(需注意部分颜色可能不符合预期)。

设置颜色模式

我们也可以通过代码的方式指定当前 app 使用哪种颜色模式

Button('切换深色模式').onClick((_) => {
  this.getUIContext()
    .getHostContext()
    ?.getApplicationContext()
    .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
});
Button('切换浅色模式').onClick((_) => {
  this.getUIContext()
    .getHostContext()
    ?.getApplicationContext()
    .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
});
Button('跟随系统').onClick((_) => {
  this.getUIContext()
    .getHostContext()
    ?.getApplicationContext()
    .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
});

效果

color_mode_light.png

color_mode_dark.png

源码

github: github.com/huangyuanlo…
gitcode: gitcode.com/huangyuan_x…

Fun with TypeScript Generics:玩转 TS 泛型

2026年3月2日 09:11

原文:Fun with TypeScript Generics

翻译:TUARAN

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

关于 TypeScript 泛型的文章有很多。本文不做基础入门,而是围绕一个真实的小问题,展示如何把泛型、条件类型和函数重载组合起来,构建一个类型完善且可维护的 API。

重点不是“类型体操”,而是在实际工程里把类型约束做对。

泛型与条件类型:快速回顾

如果你已经熟悉这些内容,可以直接跳到后面的实现部分。

泛型(Generics)

把泛型理解成“类型层面的函数参数”很有帮助。

普通函数参数是“值”,比如:

function arrayLength(arr: any[]) {
  return arr.length;
}

any[] 换成泛型之后:

function arrayLengthTyped<T>(arr: T[]) {
  return arr.length;
}

调用时 T 会从传入数组元素类型推断出来。

作者也直说:这个例子虽然“更类型安全”,但没什么意义,因为 .length 对任何数组都存在。

泛型真正发光的场景,是当你需要让类型信息在“输入 → 输出”之间流动时。例如手写一个 filter

function filterUntyped(array: any[], predicate: (item: any) => boolean): any[] {
  return array.filter(predicate);
}

这个版本的问题是:传进来的 predicate 没任何类型约束,写错字段也不会提示:

type User = {
  name: string;
};

const users: User[] = [];

filterUntyped(users, (user) => user.nameX === "John");

泛型版可以把元素类型贯穿到 predicate 中:

function filterTyped<T>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

这下 user.nameX 会被立即指出错误。

泛型约束(Constraints)

当你希望“泛型灵活,但不能什么都放进来”,就需要约束。例如你有多个 user 类型,但都至少有 name

type User = {
  name: string;
};

type AdminUser = User & {
  role: string;
};

type BannedUser = User & {
  reason: string;
};

如果把函数写死成 User[],会丢失具体子类型:

function filterUser(array: User[], predicate: (item: User) => boolean): User[] {
  return array.filter(predicate);
}

const adminUsers: AdminUser[] = [];
const adminsNamedAdam = filterUser(adminUsers, (u) => u.name === "Adam");
// adminsNamedAdam 被推断为 User[] —— 信息被“抹平”了

正确做法是保留泛型,但限制它必须是 User

function filterUserCorrect<T extends User>(array: T[], predicate: (item: T) => boolean): T[] {
  return array.filter(predicate);
}

条件类型(Conditional Types)

条件类型本质是“对类型提问,并根据答案生成新类型”。

type IsArray<T> = T extends any[] ? true : false;

type YesIsArray = IsArray<number[]>;
type NoIsNotArray = IsArray<number>;

这看起来很无聊,但配合 infer 才是精华:

type ArrayOf<T> = T extends Array<infer U> ? U : never;

type NumberType = ArrayOf<number[]>; // number
type NeverType = ArrayOf<number>; // never

你还可以加上约束,强制只接受数组:

type ArrayOf2<T extends Array<any>> = T extends Array<infer U> ? U : never;

type NumberType2 = ArrayOf2<number[]>;
type NeverType2 = ArrayOf2<number>; // Type 'number' does not satisfy the constraint 'any[]'

正文:为 refetchedQueryOptions 补齐类型

作者在使用 TanStack Start 时遇到一个工程问题:为了让 react-query 的 queryFnmeta 中记录的服务端函数信息保持一致,他写了一个 helper 来减少重复代码:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

组合使用时是这样:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

但这个 proof-of-concept 版本里,serverFnarg 都是 any,会导致两个问题:

  1. 传错参数也不会报错
  2. 更糟糕的是:queryFn 返回值也会变成 any,下游 useQuery 拿到的数据类型全部丢失

验收标准(测试用例)

作者给出了一套“应该通过/应该报错”的调用来验证类型:

import { QueryKey, queryOptions } from "@tanstack/react-query";
import { createServerFn } from "@tanstack/react-start";

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

const serverFnWithArgs = createServerFn({ method: "GET" })
  .inputValidator((arg: { value: string }) => arg)
  .handler(async () => {
    return { value: "Hello World" };
  });

const serverFnWithoutArgs = createServerFn({ method: "GET" }).handler(async () => {
  return { value: "Hello World" };
});

refetchedQueryOptions(["test"], serverFnWithArgs, { value: "" });
refetchedQueryOptions(["test"], serverFnWithoutArgs);

// wrong argument type
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs, 123);

// need an argument
// @ts-expect-error
refetchedQueryOptions(["test"], serverFnWithArgs);

目标是前两行通过,后两行必须报错。

Iteration 1:先恢复 serverFn 的类型信息

TanStack 的 server function 本质就是函数:它只有一个参数对象,常用参数在 data 字段里。既然它是函数,我们就能用 TS 内置的 Parameters / ReturnType

第一步是把 serverFn 设为泛型参数 T,并把 arg 绑定到 Parameters<T>[0]["data"]

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"]
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这样做以后:

  • arg 的类型会跟随传入的 serverFn 自动推断
  • queryFn 的返回值也能从 ReturnType<T> 推导出来

但很快出现一个新问题:即便 serverFn 不需要参数,refetchedQueryOptions 仍会强制传第三个参数。

undefined 传进去能工作:

refetchedQueryOptions(["test"], serverFnWithoutArgs, undefined);

这对大多数项目已经足够,但作者希望继续把 API 打磨到更理想的形态:

  • serverFn 需要参数时:第三个参数必须传
  • serverFn 不需要参数时:第三个参数就不该出现

正确方向:函数重载

你可能会想到把 arg 设为可选,但那会让“本应必传参数的 serverFn”也可不传,约束就失效了。

你也许会想到条件类型:如果 dataundefined 就不需要参数,否则需要参数。但 TS 里很难用条件类型表达“这个参数根本不存在”。

更直接、也更符合 TS 语言特性的解法是:函数重载

TypeScript 的函数重载回顾

重载在 TS 里分两层:

  • 多个“声明签名”(对外可见的 API)
  • 一个“实现签名”(真正的 JS 实现,参数/返回类型要覆盖所有声明)

例如:

function add(x: number, y: number): number;
function add(x: string, y: string): string;
function add(x: string | number, y: string | number): string | number {
  if (typeof x === "string" && typeof y === "string") return x + y;
  if (typeof x === "number" && typeof y === "number") return x + y;
  throw new Error("Invalid arguments");
}

原文还放了两张图,展示编辑器只会提示“声明签名”,而不会把实现签名暴露出来:

构建最终解:针对“有参/无参 serverFn”提供不同签名

我们想要两种重载:

  1. serverFn 有参数 → refetchedQueryOptions(queryKey, serverFn, arg)
  2. serverFn 无参数 → refetchedQueryOptions(queryKey, serverFn)

首先定义一个任意异步函数类型,方便复用:

type AnyAsyncFn = (...args: any[]) => Promise<any>;

接着写一个条件类型,提取 serverFn 参数里 data 的类型:

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends { data: infer TResult }
  ? TResult
  : undefined;

再用它判断“是否有参数”:

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends undefined ? false : true;

最后把函数分成“有参版本”和“无参版本”:

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

作者还提醒:TS 的重载返回值最好显式写出来,因此他定义了一个返回类型:

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn: (_?: any) => Promise<T>;
  meta: any;
};

最终重载签名

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"]
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn>
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;

真实实现

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"]
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return {
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  };
}

到这里就完成了:泛型 + 条件类型 + infer + 重载,组合起来可以“问对问题”,并把你想要的精确 API 表达出来。

结语

作者最后说:你大概率不会在日常工作里遇到完全相同的问题,但这套思路非常通用——它教你如何把类型系统当作工具,让 API 的“可用性”和“约束力”同时到位。

OpenClaw 从能聊到能干差的是这 50 个 Skills 😍😍😍

作者 Moment
2026年3月2日 09:06

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。 很多人第一次打开 OpenClaw,会下意识把它当成"接在微信或 Slack 上的聊天机器人"。这种理解只对了一半。从架构上看,OpenClaw 更像一个网关:它站在你和一堆能力之间,负责路由、鉴权、记忆和工具调用。真正决定你能做多少事的,不是对话框有多好看,而是背后接了多少"身体"——也就是 Skills。

大语言模型很像一个装在罐子里的超级大脑,读得懂天下书,推得了复杂逻辑,但它没有手。它没法自己点按钮、没法直接读你电脑里的真实文件、也没法"学会"一套新工作流,除非你每次都把那套流程写成十几页的说明塞进提示词。Skills 就是给这个大脑配上的手脚和工具箱。你通过 OpenClaw 发一句指令,网关把请求转给模型,模型按当前加载的 Skill 决定调用哪类工具、走哪套流程,最后把结果经由同一网关回到你的聊天窗口。所以,与其说你在和 OpenClaw 聊天,不如说你在通过它调度一个"大脑 + 一堆可插拔能力"的联合体。

下面这张图概括了这条链路:从你的一句话,到网关、模型与 Skills 的协作,再到结果回到聊天窗口。

20260302010904

上图从左到右依次是你发指令、OpenClaw 网关做路由与鉴权、大模型负责推理、Skills 提供手脚与工具、执行后结果再经网关回到聊天窗口;整条链路就是"一句话调度大脑与能力"。

本文会先澄清"提示词膨胀"为什么会让很多人吃亏,再说明 Skill 是什么、为什么要用 SkillGuard 管好环境安全,最后按类别梳理一批值得优先安装的 Skills,并附上各类别的示意配图 prompt,方便你从"聊天思维"切换到"基础设施思维"。

提示词膨胀:为什么你还在吃亏

如果还没用上 Skill,大概率会落在两种处境之一。

第一种是提示词工程的无底洞。为了让人工智能好好干一次活,你得花十几二十分钟写"完美提示词",把背景、约束、输出格式、反例全塞进去。一次两次还行,任务一换又要重写,而且同一个任务下次还想复用时,又得翻聊天记录或文档把那一大段再贴一遍。成本高、难复用、还容易忘。

第二种是上下文超载。为了"让 AI 更懂我",很多人会在系统提示里塞进大量个性设定、操作指令和禁忌清单。结果模型还没开始干活,上下文窗口已经占掉一大截,轻则推理变慢、容易截断,重则直接爆掉,顺便把 API 额度烧光。更麻烦的是,这些设定和任务强绑定在一起,换一个任务又得改一版提示,维护成本越来越高。

Skill 要解决的就是这两类问题。它把"怎么用某类工具、走某套流程"打包成独立文件(或指令集),只在需要时加载。OpenClaw 和你用的 Agent 只加载当前任务需要的"战术手册",其余时间保持轻量。这样 Agent 能保持敏锐、成本可控、结果也更容易复现。你不是在跟一个越聊越臃肿的聊天窗口较劲,而是在维护一套可组合的能力层。

Skill 是什么,以及为什么要用 SkillGuard

从技术上说,一个 Skill 就是一个打包好的指令集,通常以文件形式存在,用来告诉 Agent 如何使用特定工具或执行某类复杂工作流。

区别在于指令的粒度。不写 Skill 时,你只能说"请帮我看看这段代码有没有错误",模型只能根据当次对话里的零散信息自由发挥。有了 Skill,你可以说"用 debug-pro 这个 Skill 对我的仓库做一次全面扫描,检查日志,然后在终端里按规范修复语法错误"。后者是"指名用哪套能力、按什么流程做",前者是"随便你怎么做"。前者依赖每次的提示词质量,后者依赖事先定义好的能力包,可复用、可审计、可迭代。

Skill 来源一多,风险就来了。ClawHub 等技能市场上有大量第三方 Skill,质量参差不齐,甚至混入恶意代码(窃取凭证、反向 shell、挖矿等),OpenClaw 的架构又给了 Agent 系统级权限和对外通信能力,一旦装上恶意 Skill,影响会很大。所以官方和社区都强调用 SkillGuard 一类机制做监控:在真正执行或下载 Skill 前,做来源校验、行为扫描或沙箱隔离,在"环境安全"和"持续扩展能力"之间取得平衡。如果你打算认真把 OpenClaw 当基础设施用,建议从第一天就打开 SkillGuard,养成"先验再装"的习惯。

从聊天思维到基础设施思维

大多数人会淹没在二十多万个可用 Skill 里,不知道从哪装起。真正能跑赢一票"只会聊天"用户的,往往是那些先把基础能力打牢的人。下面按类别整理一批值得优先考虑的 Skills,并说明它们各自解决什么问题、适合谁用;每一类下面都附了一张"这类在做什么"的示意配图 prompt,你可以丢进文生图模型生成插图,再替换文中的代码块。你可以按自己的角色(独立开发者、内容创作者、做增长的人等)勾选几条主线,再逐步扩展。

基础 Skills:给 AI 装上手脚

如果感觉 OpenClaw"也就那样",多半不是模型不够强,而是管道太薄。把这层当成"操作系统"来配,而不是聊天窗口,会顺很多。下面六个是很多人会优先装的基础能力。

  • find-skills:技能市场里 Skill 数量巨大,手动搜既慢又容易漏。这个 Skill 让 Agent 能按任务描述自动发现、筛选合适的 Skill,相当于导航员。
  • skill-creator:把你自己的工作流和"氛围编程"逻辑打包成可复用 Skill,相当于规模化复制你的习惯和判断,让 AI 反复按同一套标准执行。
  • mcp-builder:通过模型上下文协议(MCP)把 AI 接到私人数据和外部工具上,是 2026 年很多人会装的"桥梁"类 Skill,没有它,很多高级能力接不进来。
  • using-superpowers:强制 Agent 先搞清楚自己有哪些高级能力、在什么场景下用哪一项,而不是凭感觉瞎试,相当于优化器。
  • subagent-driven-development:把大任务拆成子任务,委派给子 Agent 执行并做结果审核,主 Agent 专注规划和验收,适合不想在一个提示词里搞定所有事的场景。
  • agent-tools:给 Agent 一整套常用小工具,处理那些模型默认不擅长、但又很常见的琐事,相当于数字瑞士军刀。

适合谁用:独立创始人、独立开发者,以及不想"手把手教 AI 走每一步"的人。

下面这张图代表"基础 Skills"在做什么:给 AI 装上导航、桥梁、优化器、委派与工具箱。

20260302010149

上图概括了基础类 Skills 的五个角色:找 Skill、接数据、优化能力认知、委派子任务、以及日常小工具,合起来就是让 AI 从"只会聊"变成"能动手"。

策略与创作 Skills:从打字员到战略伙伴

如果 Agent 的输出总像机器人在填废话,多半缺的是"先想清楚再写"的环节。下面这类 Skill 会引入反思、大纲和策略,把 OpenClaw 从快速打字员变成能提前验证逻辑的伙伴。

  • brainstorming:从一个关键词展开多种角度和"如果……会怎样"的场景,避免只停留在第一个听起来很"AI"的念头。
  • copywriting:不只生成文案,还管结构、节奏和语气,减少对老套模板的依赖。
  • systematic-debugging:一套可复用的排查框架,既能用来查代码,也能用来分析项目计划或异常情况。
  • writing-plans:动笔前先建大纲和内容策略,避免长文写飞。
  • content-strategy:选题和内容日历规划,让发布有节奏、有品牌感。
  • executing-plans:把高层计划拆成可执行的多步任务,由 Agent 按顺序完成。
  • marketing-ideas:给一个功能或时间节点,用成熟营销框架产出传播点和活动概念。
  • copy-editing:在纠错之外,顺带打磨语气和逻辑,尽量保留你个人的表达习惯。
  • social-content:把同一套核心信息改写成适合不同平台(如 X、TikTok、小红书)的版本,照顾各处的阅读习惯。
  • reflection:在流程里加入自检和纠错环节,让 Agent 能回顾自己的输出、总结经验、在过程中修正错误。

适合谁用:做内容、做个人品牌、或者想从"对着空白页发愁"变成"稳定产出但不丢灵魂"的人。

下面这张图代表"策略与创作 Skills"在做什么:从想法到大纲、文案、多平台分发与反思。

20260302010315

上图从左到右是创意展开、大纲与计划、文案打磨、多平台分发、按计划执行和反思自检,对应"先想清楚再写、写完后还能改"的一整条创作链。

工程 Skills:用氛围编程的速度,达到生产级质量

氛围编程已经是 2026 年的常态,但光靠对话很难保证生产级可维护性和测试覆盖。下面这些 Skill 把工程最佳实践编码进去,让 Agent 在写代码时更贴近真实团队的标准。

  • vercel-react-best-practicesvercel-composition-patterns:React 与 Vercel 生态下的前端规范和组件组合方式。
  • remotion-best-practices:用代码驱动视频与动画,适合程序化营销素材和产品演示。
  • agent-browserbrowser-use:让 Agent 能浏览网页、填表、抓取数据或做自动化测试,相当于给 AI 装上"眼睛"和"手"。
  • vercel-react-native-skills:移动端跨平台开发时的 React Native 最佳实践。
  • supabase-postgres-best-practices:数据库设计与 PostgreSQL 使用上的建议。
  • next-best-practices:Next.js 的架构与路由等最新模式。
  • webapp-testingtest-driven-development:测试驱动与自动化测试,减少回归和边缘问题。
  • requesting-code-review:在发布前让 Agent 自审代码,查找潜在漏洞和坏味道。

适合谁用:自己做产品、用氛围编程但希望交付物更稳、更可维护的创始人或开发者。

下面这张图代表"工程 Skills"在做什么:从代码规范、测试、到浏览器自动化与发布前审查。

20260302010430

上图概括了工程类 Skills 覆盖的几块:前端与全栈规范、数据库、测试与 TDD、浏览器自动化、视频与移动端,以及发布前的代码自审,对应"用对话的速度写出可维护的代码"。

设计 Skills:不碰 Figma 也能要专业审美

不需要自己会画图,也能让 Agent 产出符合设计原则的界面和素材。

  • web-design-guidelinesfrontend-design:按网格、色板等原则审视前端实现,避免"通用 AI 丑"。
  • ui-ux-pro-max:跨技术栈的设计与无障碍建议。
  • canvas-design:把想法转成 PNG、PDF 等视觉素材。
  • tailwind-design-system:用 Tailwind 令牌和无障碍模式搭可复用的 UI 库。
  • content-visualizerinfographic-pro:根据内容推荐版式、生成信息图或封面图。
  • ai-image-generation:统一调用多模型(如 DALL·E、Imagen、Gemini)做原型和创意图。

适合谁用:做落地页、做高保真内容、希望产品"长得像样"但暂时不打算雇设计团队的开发者或创始人。

下面这张图代表"设计 Skills"在做什么:从网格与色板、到信息图与多模型出图。

20260302010535

上图从左到右是设计原则与色板、前端与组件库、UX 与无障碍、插画与素材、信息图与封面推荐,以及多模型统一出图,对应"不自己画图也能要专业视觉"。

增长 Skills:把 OpenClaw 当增长引擎

产品做出来只是前半段,卖不出去等于没存在感。这类 Skill 覆盖从 SEO、转化到心理诱因的链条。

  • Larry:结合图像生成和病毒式钩子,自动化 TikTok 等平台的图文内容。
  • audit-websiteseo-audit:网站健康与 SEO 缺口扫描,减少盲目猜测。
  • marketing-psychology:把社会认同、稀缺性等原理用到产品钩子和文案里。
  • programmatic-seo:规模化生成大量 SEO 友好页面的工作流。
  • product-marketing-contextpricing-strategypage-cro:定位、定价和落地页转化优化。

适合谁用:独立黑客、独立创始人,以及在没有大预算的情况下也要拉新和变现的营销或产品人。

下面这张图代表"增长 Skills"在做什么:从 SEO、网站健康、到心理诱因与转化。

20260302010700

上图概括了增长类 Skills 的链条:网站与 SEO 诊断、病毒式内容、营销心理、规模化 SEO、产品定位与定价,以及落地页转化优化,对应"产品做出来之后怎么卖出去"。

文档 Skills:把 AI 当行政助理

合并拆分 PDF、做 PPT、出 Word/Excel、把网页转成 Markdown 或 HTML,这类"文书劳动"交给专门 Skill 处理,能省下大量时间。

  • pdf-propptxdocxxlsx:对应格式的生成与编辑能力。
  • url-to-markdownmarkdown-to-html:为知识库或发布流程做格式转换。
  • format-pro:统一文档版式和样式,保持品牌一致。

适合谁用:经常要出报告、提案、知识库或对外材料的创始人、学生或专业人士。

下面这张图代表"文档 Skills"在做什么:从 PDF、PPT、Word、Excel 到格式转换与统一版式。

20260302010751

上图从左到右是 PDF 处理、PPT 与 Word/Excel 生成、网页转 Markdown、Markdown 转 HTML 发布,以及版式与品牌统一,对应"文书杂活交给 AI 省时间"。

小结

OpenClaw 的价值不在于多一个聊天入口,而在于它把"模型 + 记忆 + 工具 + 技能市场"串成了一条可扩展的管道。你发出去的那句话,会经过网关被转成对某几个 Skill 的调用,再驱动真正的读写、执行和对外请求。想把它用成 2026 年能持续跑下去的基础设施,就需要从"聊天"思维切到"网关 + 能力层"思维:先装好基础 Skills、打开 SkillGuard,再按自己的角色补上策略、工程、设计、增长或文档中的几条线,让 Agent 既有手脚,又处在可控、可审计的环境里。

从 0 到 1 实现一个 useState

作者 印刻君
2026年3月2日 08:49

大家好,我是印刻君。如果你是前端程序员,相信对 React 并不陌生。但你有没有想过,如果 React 没有给你提供 useState,你能否自己实现一个呢

今天我就带你从零实现一个 useState。我会先梳理它必备的核心能力,再用极简代码实现一个功能完整的基础版,在基础版之上,我最终会实现一个更严谨的进阶版。

一、useState 的核心能力拆解

动手前,我们先明确一个合格的 useState 必须具备的核心能力。具体有以下几点:

1.1 数据持久化与触发重新渲染

我们通过一个简单示例理解这两个能力:

function App() {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount(count + 1)
  }
  return (
    <div>
      <p>{count}</p>
      <button onClick={handleClick}>计数</button>
    </div>
  )
}

1.1.1 数据持久化

组件首次挂载时,count 的值是 0;点击“计数”按钮之后,组件会重新执行,但 count 的值并没有变回初始值 0,而是“记住”了更新之后的 1。

这种在组件多次渲染中记忆数据,不被重置的性质就是 state 的数据持久化能力。

1.1.2 触发重新渲染

点击“计数”按钮之后,setCount 会更新 count 的值,React 检测到 state 发生变化,会再执行一遍组件函数。

这种检测 state 变化并更新组件的性质,就是触发重新渲染

1.2 setState 支持函数式更新

如下面代码示例,setState 既可以传入一个值,也可以传入一个回调函数。

传入回调函数的方式,就是函数式更新。

const handleClick = () => {
  setCount(prev => prev + 1)
}

1.3 setState 支持批量更新

当你连续多次调用 setState(比如示例中的 setCount 时),并不会触发多次渲染。React 会把这些更新操作整合起来,只触发一次渲染。

这就是 setState 的批量更新特性。

const handleClick = () => {
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
  setCount(prev => prev + 1)
}

1.4 useState 可多次调用,创建多个相互独立的状态

在一个组件中,你可以多次调用 useState,每次调用会创建一个独立的状态单元(包括状态值和更新函数)。

比如示例中,更新 count 并不会影响 str 和 time,修改 str 也不会影响 count 和 time。

function App() {
  const [count, setCount] = useState(0)
  const [str, setStr] = useState('ink')
  const [time, setTime] = useState(Date.now())
  
  // ...
}

二、基础版的 useState

2.0 环境准备

要自己实现 useState 并验证是否符合预期,我们需要先搭建一个简单的浏览器运行环境,也就是下方的 HTML 模板:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>从零实现 useState</title>
  <script
    src="https://unpkg.com/@babel/standalone/babel.min.js"
  ></script>
  <script
    src="https://unpkg.com/react@18/umd/react.development.js"
  ></script>
  <script
    src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
  ></script>
</head>
<body>
  <div id="root"></div>
  <script type="text/babel">
    const root = ReactDOM.createRoot(document.getElementById('root'))
    function App() {
      return (
        <p>从零开始实现 useState</p>
      )
    }
    function render() {
      root.render(<App />)
    }
    render()
  </script>
</body>
</html>

其中:

  1. 引入 Babel 是为了让浏览器识别 JSX 语法;
  2. 引入 ReactDOM 是为了借助它的渲染能力,把组件显示到浏览器中,方便我们验证自己写的 useState 是否生效;
  3. 引入 React 是因为 ReactDOM 内部代码依赖它,但我们全程不会使用 React 自带的 useState,而是自己实现。

整个模板的功能,就是把 App 组件渲染到页面的 root 节点上,方便我们后续测试自定义 useState。

2.1 实现数据持久化、触发重新渲染

2.1.1 实现 state 数据持久化

在之前准备的 HTML 模板基础上,我们先实现最基础的 state 数据持久化功能。要做到这一点,需要满足两个要求:

1. state 不随函数执行重置

state 需要在函数内执行,但更新组件时不能被重置,这意味着 state 不能存储在函数内部(局部变量会在函数执行后销毁),而必须存储函数外部作为全局变量。

2. state 只在首次渲染时初始化

state 只在第一次执行时用初始值初始化,后续不能重复初始化。这要求我们区分”首次渲染“和”更新渲染“两个时机,我们可以用 state 是否为 undefined 来区分。

满足要求的代码如下:

let state
function render() {
  root.render(<App />)
}
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal
  }
  function setState(newVal) {
    state = newVal
  }
  return [state, setState]
}

2.1.2 实现 setState 触发组件重新渲染

接下来我们给自定义的 useState 补上“调用 setState”触发渲染的能力。

在 React 源码中,调度更新的模块叫做 Scheduler,负责决定"什么时候更新"。

我们这里简化处理,不实现优先级调度,直接在 setState 后立即触发渲染,把这个函数叫做 schedule 以示致敬。

function schedule() {
  render()
}
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal
  }
  function setState(newVal) {
    state = newVal
    schedule()
  }
  return [state, setState]
}

2.1.3 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/a…

2.2 实现函数式更新

2.2.1 实现逻辑

接下来我们给 setState 增加函数式更新 的能力。核心逻辑是:

如果 setState 的参数类型是函数,就把“上一次的 state”传给这个函数,并把函数的返回值作为新的 state;

如果 setState 的参数类型是普通值,直接用这个值更新 state。

function useState(initialVal) {
  // ...
  const prevState = state
  function setState(action) {
    state = typeof action === 'function'
          ? action(prevState)
          : action
    schedule()
  }
  
  return [state, setState]
}

2.2.2 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/w…

2.3 实现批量更新

先说明,我们接下来实现的是手动批量更新(需要主动调用方法包裹 setState),语法如下:

const handleClick = () => {
  batchUpdate(() => {
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
  })
}

而 React 源码中的是自动批量更新(不需要主动调用方法包裹 setState)。

我们选择手动批量更新是为了简化理解,核心逻辑和 React 源码的批量更新是一致的。

2.3.1 核心思路

批量更新的本质是“先收集所有更新任务,最后一次性执行”,实现这个逻辑需要两个全局变量、两个核心函数。

1. 两个全局变量
  • queue,更新队列,专门用来存放 setState 更新任务;
  • isBatchingUpdates,更新标记,用来判断当前是否处于批量更新阶段。
let queue = []
let isBatchingUpdates = false
// ...
function useState(initialVal) {
  if (state === undefined) {
    state = initialVal;
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:把更新任务加入队列
      queue.push(action);
    } else {
      // 非批量阶段:直接更新 state, 立即渲染
      const prevState = state;
      state = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  return [state, setState];
}
2. 两个核心函数
  • flushUpdates,执行队列内所有的更新任务,返回最后的 state;
function flushUpdates() {
  let currentState = state;
  // 遍历队列,依次执行每个更新任务
  while (queue.length > 0) {
    const update = queue.shift();
    currentState = typeof update === "function" ? update(currentState) : update;
  }
  return currentState;
}
  • batchUpdate,手动开启批量更新
function batchUpdate(callback) {
  isBatchingUpdates = true;
  try {
    // 执行用户传入的回调(里面会调用多次 setState)
    callback();
  } finally {
    isBatchingUpdates = false;
    // 批量阶段结束后,执行所有更新任务并更新 state
    state = flushUpdates();
    schedule();
  }
}

2.3.2 完整代码

结合 HTML 模板的完整代码:codesandbox.io/p/sandbox/3…

2.3.3 React 原生批量更新的特性

不同 React 版本中,setState 的“同步/异步”表现不同,核心原因是批量更新标记的开启规则不一样:

React18 之前

批量更新仅在 React 管控的场景自动开启(比如点击/输入等合成事件,useEffect/生命周期钩子),此时 setState 会先收集任务、延迟渲染,表现为“异步”;

而在原生事件(比如 document.onclick)、定时器(setTimeout)和 Promise 回调中,批量更新标记未开启,useState 会立即更新并渲染,表现为“同步”。

React18 之后

批量更新标记默认全局开启,几乎所有场景下 setState 都是“异步”的。只有用 flushSync 包裹时,才会强制同步更新。

2.4 支持多个 useState

2.4.1 核心思路

我们之前只在全局定义了一个 state,如果多次调用 useState(比如同时定义 count 和 age 两个状态),会导致状态值混乱,要解决这个问题,核心思路如下:

1. 用数组存储多个 state

把全局的单个 state 改成 state 数组,每个 useState 都对应 state 数组中的一个元素。

let stateArr = []

2. 用下标(调用顺序)来匹配 state 用数组存储多个 state 后,我们需要在调用 setState 时准确知道更新数组中的哪一个 state。

因此我们需要靠下标(调用顺序)来匹配 state。

在全局维护一个 hookIndex 变量,每调用一次 useState,hookIndex 变量就自增 1(保存在 useState 的闭包中),这样每个 useState 都对应数组中的一个固定下标,就能精准匹配自己的 state。

let stateArr = [];
let hookIndex = 0;
// ...
function useState(initialVal) {
  const currentIndex = hookIndex;
  if (!stateArr[currentIndex]) {
    stateArr[currentIndex] = initialVal;
    // ...
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // ...
    } else {
      // 非批量阶段:直接更新对应下标的 state,立即渲染
      const prevState = stateArr[currentIndex];
      stateArr[currentIndex] = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  // 索引自增,匹配下一次 useState 调用
  hookIndex++;
  return [stateArr[currentIndex], setState];
}

3. 批量更新队列也要适配多状态

之前为单个 useState 维护了一个批量更新队列,现在需要支持多个 useState,单个队列也需要改造为队列数组。

let queueArr = [];
// ...
function flushUpdates() {
  // 遍历每个 state 的更新队列,执行更新
  for (let i = 0; i < queueArr.length; i++) {
    const queue = queueArr[i] || [];
    let currentState = stateArr[i];
    while (queue.length > 0) {
      const update = queue.shift();
      currentState = typeof update === "function" ? update(currentState) : update;
    }
    stateArr[i] = currentState; // 更新对应下标的state
    queueArr[i] = []; // 清空当前队列
  }
}
// ...
function useState(initialVal) {
  const currentIndex = hookIndex;
  if (!stateArr[currentIndex]) {
    // ...
    queueArr[currentIndex] = []; // 初始化对应下标的更新队列
  }
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:把更新任务加入当前下标对应的队列
      queueArr[currentIndex].push(action);
    } else {
      // ...
    }
  }
  // ...
}

2.4.2 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/j…

2.4.3 为什么 useState 不能放在 if 和 for 循环中

我们用数组实现的 useState 有个关键限制:必须在组件顶层调用,不能放在 if、for 循环里,否则会导致状态错乱。

比如下面例子,就是条件语句导致状态混乱。因为 if 的存在,flag 对应的 hookIndex 不固定,状态就会匹配错误。

function App() {
  const [count, setCount] = useState(0)   // hookIndex = 0
  
  if (count > 0) {
    const [extra, setExtra] = useState(0) // ❌ 错误!条件调用
  }
  
  const [flag, setFlag] = useState(false) // hookIndex 可能是 1 或 2,无法确定!
}

React 源码没有用数组存储 Hooks,而是用链表(每个 Hook 是一个节点,调用时从当前节点移到下一个)。但核心逻辑和数组实现一致,都依赖固定的调用顺序匹配状态,因此依然要遵守规则:useState 不能放在 if、for 循环中。

三、进阶版:基于链表的 useState

3.1 数组方案的致命缺陷:无法适配 React 18 并发模式

我们之前用数组实现的 useState,在同步渲染下能正常工作,但在 React18 的并发模式(Concurrent Mode)下会彻底失效。因为并发模式下可能中断渲染,一旦中断渲染,数组的索引就会全部错乱。

3.1.1 什么是并发模式?

我们知道,浏览器的刷新帧率约为 60fps,也就是大概每 16.6ms 刷新一次。这意味着如果一段 JavaScript 代码执行时间超过 16.6ms,就会阻塞页面刷新,导致卡顿。

React 的并发模式,就是允许中断渲染过程(比如优先处理用户点击、输入等高频交互),等浏览器空闲时再恢复渲染,避免页面卡顿。

3.1.2 数组在并发模式下的问题

数组实现的 useState 依靠全局唯一的 hookIndex 来匹配状态:

如果组件渲染过程被中断(比如渲染到一半,hookIndex 刚走到 2),等恢复渲染时,全局 hookIndex 并不会自动回到中断前的位置,而是会继续往后自增,这就会导致后续的 useState 与状态数组的下标 错位,最终状态匹配错误。

链表实现则可以很好地解决这个问题

链表不依赖全局索引,而是为每个组件独立维护一条 Hook 链表,并只用一个当前节点指针来记录遍历位置。渲染中断时,只需要保存当前指针指向的 Hook 节点;恢复渲染时,直接从这个节点继续往下遍历即可,不会出现索引错乱、状态错位的问题。

3.2 利用链表替代数组

现在我们基于之前的数组版本,把 useState 改造成链表实现,这样可以适配并发模式,更贴合 React 源码。

改造可以分为 4 个关键步骤:

3.2.1 定义 Hook 链表节点(替换数组存储)

首先会删除全局的 stateArr(状态数组)和 hookIndex(状态索引),改用链表节点存储每个 useState 的状态。

  • 每个 Hook 节点包含状态值、更新队列,以及指向下一个节点的指针;
  • 用 rootHook 记录链表的头节点,利用 currentHook 记录链表的当前节点。
// 定义单个 Hook 节点结构(链表核心)
function createHookNode(initialVal) {
  return {
    state: initialVal, // 当前 Hook 的状态值
    queue: [], // 当前 Hook 的更新队列
    next: null // 指向下一个 Hook 节点的指针
  };
}
// 链表核心指针:rootHook(链表头)、currentHook(当前遍历节点)
let rootHook = null;
let currentHook = null;

3.2.2 渲染函数适配(重置链表指针)

每次组件渲染前,需要把 currentHook 重置到链表头(rootHook),替代原本 hookIndex = 0 的逻辑,这样可以保证每次渲染时都从第一个 Hook 节点开始遍历。

function render() {
  currentHook = rootHook;
  root.render(<App />);
}

3.2.3 适配 useState 函数(遍历链表匹配状态)

之前靠自增 hookIndex 找到对应的 state,现在改为遍历链表(移动 currentHook 指针)匹配状态:

  • 首次渲染时,创建新的 Hook 节点并挂载到链表末尾;
  • 调用 setState 时,操作当前节点的状态 / 队列,而非数组下标;
  • 每次调用完 useState,把指针移到下一个节点(替代原来的 hookIndex++)。
function useState(initialVal) {
  // 首次渲染:创建新节点,初始化链表
  if (!currentHook) {
    const newHook = createHookNode(initialVal);
    // 链表为空时,rootHook指向第一个节点
    if (!rootHook) {
      rootHook = newHook;
    } else {
      // 链表已有节点,挂载到当前节点的next
      let lastHook = rootHook;
      while (lastHook.next) {
        lastHook = lastHook.next;
      }
      lastHook.next = newHook;
    }
    currentHook = newHook;
  }
  // 保存当前节点(避免后续指针移动影响)
  const hookNode = currentHook;
  function setState(action) {
    if (isBatchingUpdates) {
      // 批量阶段:加入当前节点的更新队列
      hookNode.queue.push(action);
    } else {
      // 非批量阶段:直接更新状态并渲染
      const prevState = hookNode.state;
      hookNode.state = typeof action === "function" ? action(prevState) : action;
      schedule();
    }
  }
  // 移动指针到下一个节点(替代原hookIndex++)
  currentHook = currentHook.next;
  return [hookNode.state, setState];
}

3.2.4 批量更新函数适配

之前批量更新是遍历 “队列数组”,现在更新队列存在每个链表节点里,因此改为遍历整个链表,逐个执行节点的更新任务。

function flushUpdates() {
  // 遍历整个Hook链表,执行每个节点的更新队列
  let hook = rootHook;
  while (hook) {
    const queue = hook.queue;
    let currentState = hook.state;
    // 执行当前节点的所有更新任务
    while (queue.length > 0) {
      const update = queue.shift();
      currentState = typeof update === "function" ? update(currentState) : update;
    }
    hook.state = currentState; // 更新节点状态
    hook.queue = []; // 清空队列
    hook = hook.next; // 移动到下一个节点
  }
}

3.2.5 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/l…

3.3 利用环状链表替换队列

React 源码中,Hook 的更新队列并非普通数组,而是环状链表(循环链表)。相比普通数组,环状链表在 “频繁新增 / 删除更新任务” 时性能更高,且能更高效地处理并发模式下的更新中断 / 恢复。

为了更贴合 React 源码,我们把每个 Hook 节点中的 queue 替换为环状链表队列,并适配对应的 “入队、遍历执行” 逻辑。 大概可以分为 4 个步骤:

3.3.1 定义环状链表的节点

我们先创建环状链表的基础单元(单个更新任务),每个节点包含:

  • action:更新动作(比如 prev => prev + 1);
  • next:指向下一个更新任务节点的指针(最后一个节点的 next 指向头节点)。
// 新增:定义环状链表的更新任务节点
function createUpdateNode(action) {
  return {
    action: action, // 存储更新动作(值/函数)
    next: null      // 指向下一个更新任务节点
  };
}

3.3.2 修改 Hook 节点结构(替换普通数组队列)

我们把 Hook 节点中的 queue 替换为环状链表的核心指针:

  • queueHead:更新队列的头节点(默认 null);
  • queueTail:更新队列的尾节点(默认 null),环状链表的 queueTail.next = queueHead。
// 改造:Hook节点不再用数组队列,改用环状链表指针
function createHookNode(initialVal) {
  return {
    state: initialVal,    // 当前Hook的状态值
    queueHead: null,      // 更新队列头节点(环状链表)
    queueTail: null,      // 更新队列尾节点(环状链表)
    next: null            // 指向下一个Hook节点的指针
  };
}

3.3.3 适配 setState 入队逻辑(新增任务到环状链表)

原来的 hookNode.queue.push(action) 替换为 “环状链表入队”:

  • 若队列为空:头/尾节点都指向新任务;
  • 若队列非空:尾节点的 next 指向新任务,更新尾节点,且尾节点 next 指向头节点(形成环)。
// 新增:更新任务入队(环状链表)
function enqueueUpdate(hookNode, action) {
  const newNode = createUpdateNode(action);
  // 队列为空:头/尾节点都指向新节点
  if (!hookNode.queueHead) {
    hookNode.queueHead = newNode;
    hookNode.queueTail = newNode;
    newNode.next = newNode; // 环状:自己指向自己
  } else {
    // 队列非空:尾节点next指向新节点,更新尾节点,形成环
    hookNode.queueTail.next = newNode;
    hookNode.queueTail = newNode;
    newNode.next = hookNode.queueHead;
  }
}
// 改造useState中的setState:
function setState(action) {
  if (isBatchingUpdates) {
    // 替换:数组push → 环状链表入队
    enqueueUpdate(hookNode, action);
  } else {
    // 非批量逻辑不变(仅演示批量场景,非批量可复用入队+执行逻辑)
    const prevState = hookNode.state;
    hookNode.state = typeof action === "function" ? action(prevState) : action;
    schedule();
  }
}

3.3.4 适配 flushUpdates(遍历环状链表执行更新)

原来的 “遍历数组 queue.shift ()” 替换为 “遍历环状链表”:

  • 从队列头开始遍历,直到回到头节点(环状终止条件);
  • 执行所有更新任务后,清空环状链表(重置 head/tail 为 null)。
// 改造:批量更新核心(遍历环状链表执行更新)
function flushUpdates() {
  let hook = rootHook;
  while (hook) {
    const head = hook.queueHead;
    let currentState = hook.state;
    
    // 若有更新任务,遍历环状链表
    if (head) {
      let currentNode = head;
      // 环状链表遍历:直到回到头节点(终止)
      do {
        const action = currentNode.action;
        // 执行更新动作(和原逻辑一致)
        currentState = typeof action === "function" ? action(currentState) : action;
        currentNode = currentNode.next;
      } while (currentNode !== head); // 环状终止条件
      // 执行完所有任务,清空环状链表
      hook.queueHead = null;
      hook.queueTail = null;
      // 更新Hook节点的最终状态
      hook.state = currentState;
    }
    hook = hook.next; // 移动到下一个Hook节点
  }
}

3.3.5 完整代码

结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/3…

四、总结

本篇文章,我拆解了 useState 的核心能力,并完成了基础版到进阶版的手写。通过从 0 到 1 实现一个 useState,你知道了 useState 的核心能力、设计思路和局限。

相信了解这些,能帮助吃透底层原理,从而更轻松应对面试,也更快地在日常开发中定位问题。

我是印刻君,一位探索 AI 的前端程序员。关注我,让前端知识有温度,技术落地有深度。

栗子前端技术周刊第 118 期 - Oxfmt Beta、Angular GitHub stars、React 基金会...

2026年3月2日 08:45

🌰栗子前端技术周刊第 118 期 (2026.02.23 - 2026.03.01):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. Oxfmt Beta:一款 100% 兼容 Prettier 的 JavaScript 代码格式化工具(同时也是 Oxlint 的姊妹项目),它号称速度比 Prettier 快 30 倍,比 Biome 快 3 倍。

  2. Angular GitHub stars:Angular 在 GitHub 上的星标数刚刚突破 10 万。

  3. React 基金会正式成立:React 基金会正式成立,React、React Native 和 JSX 现已不再归 Meta 所有,而是由 Linux 基金会托管的一家独立基金会掌管。React 生态中的多家核心企业(Meta、Vercel、微软等)组成创始董事会,由 Seth Webster 担任执行董事。

  4. Node.js 25.7.0:Node.js v25.7.0(当前版)和 v24.14.0(长期支持版)已发布,node:sqlite 现已进入候选发布(RC)阶段。

  5. Deno 2.7:Deno 2.7 正式发布,实现了 Temporal API 支持的稳定化,新增对 ARM 架构 Windows 系统的支持,增加了对 package.json 中覆盖配置(overrides)的支持,同时提升了 Node.js 兼容性等等。

📒 技术文章

  1. Virtual Scrolling for Billions of Rows — Techniques from HighTable:数十亿行数据的虚拟滚动 —— 来自 HighTable 的技术。在本文中,作者介绍了 <HighTable> 组件中用到的五项与垂直滚动相关的技术,这是一个 React 表格组件,能够渲染数十亿行数据,同时保持出色的性能与可访问性。

  2. 空数组的迷惑行为:为什么 every 为真,some 为假?:在 JavaScript 中,对空数组使用 everysome 方法会得到相反的结果,本文将介绍这一现象并解释其原理。

  3. 构建工具的第三次革命:从 Rollup 到 Rust Bundler,我是如何设计 robuild 的:本文将从作者的实战视角,深入探讨前端构建工具的技术演进,以及作者在设计 robuild 过程中的架构思考与工程实践。

🔧 开发工具

  1. TanStack Hotkeys: 一款类型安全、跨平台的热键库,提供了适用于 React 等框架的适配器。
image-20260301125930026
  1. bignumber.js 10.0:一款用于任意精度算术运算的库,可规避 JavaScript 中 Number 和 BigInt 类型的局限性(例如,当你需要处理超大非整数数值时)。
image-20260301132213323
  1. Beautiful Mermaid 1.0:可通过 JavaScript 将 Mermaid 图表标记渲染为 SVG 或 ASCII/Unicode 格式输出。
image-20260301132250452

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

小伙伴说我的拼图游戏用Mask不能合批...

2026年3月2日 08:31

拼图游戏又双叒叕来了

引言

哈喽大家好,我是亿元程序员。

不知道小伙伴们还记不记得笔者上次通过Mask实现拼图游戏的圆角效果。

那期文章一发出去就收到了很多小伙伴们的评论,其中呼声比较高的:

太low了

当然也有很多小伙伴说现在手机性能都相对来说比较高了,没必要纠结手表能不能玩:

手表都要能玩?

笔者觉得还是可以优化一下的,不然写文章都没有素材了,建议大家不要学。

言归正传,本期带大家一起来看看,在Cocos游戏开发中,如何通过Shader实现能够合批的拼图游戏,将DC从400多降到个位数

本文源工程可在文末获取,小伙伴们自行前往。

回顾一下

上期笔者讲解了如何通过Mask实现圆角拼图,有很多小伙伴提出了不能合批的问题,先简单科普一下:

合批就是将多个需要绘制的图形元素合并在一起,然后一次性提交给GPU进行渲染。

这样做可以显著减少CPU调用图形API绘制命令的次数,也就是降低DrawCall

笔者把上一次的拼图改成10*10,也就是100张拼图时,DC高达404,差点就找不到了:

404 not found

**怎么来的?**我们简单分析一下:

拆分一下

其中:

  • Mask2个。

  • Sprite1个。

  • Graphics: 1个。

所以总数高达4*100,也就是400DC

那我们要怎么才能合批,把DC降下来?

不用Mask

既然MaskGraphics占大头,我们只能换个方式了。

美术妹子

上期我们讲到,实现圆角效果除了Mask以外,还可以通过Shader或者自定义Mesh来完成,本期我们通过Shader来完成。

拆解一下

如上图通过Shader实现圆角效果的原理也比较简单,把拼图块分成以下4个部分:

  • 1.图片部分:保留原图片纹理。

  • 2.白框部分:设置白色。

  • 3.黑框部分:设置黑色。

  • 4.框外部分:设置透明。

我们只需要通过距离判断当前像素处于以上4个部分的哪一个部分,即可完成Shader的编写。

下面我们一起来进行圆角Shader实战。

圆角Shader实战

下面开始实战,本次实战不代表最优解,欢迎小伙伴们评论区一起探讨,这部分比较枯燥,小伙伴们可以直接拉进度条

1.创建Shader

我们依旧在资源管理器中搜素sprite,找到builtin-sprite这个Shader,然后Ctrl+C/V到我们的assets目录下。

快速创建Shader

2.添加properties

想要计算像素处于哪一部分,我们需要通过一些列的参数来计算判断。

一些列的参数

3.定义uniform

图解

在片段着色器中接收参数(是否圆角、是否有边框、uv范围、纹理大小、圆角半径、黑框宽、白框宽、黑框内嵌等等)。

接收

4.计算各层的边界位置

图解

在片段着色器中计算各层的边界位置:

  • 黑框外层边界(最外层):

  • 黑框内层边界(白框外边界):

  • 图片层边界(白框内边界):

5.计算各层的中心点和半尺寸

图解

计算各层的中心点和半尺寸,同时计算各层的圆角半径。

6.计算当前像素到各层边界的距离

通过SDF方法计算出像素到各层边界的距离。

得出像素处于哪一层:

7.根据距离判断像素属于哪一层,并设置颜色

图解

最后根据距离判断像素属于哪一层,并设置颜色,即可完成对应的效果。

8.应用

创建材质并且应用到sprite

最后我们通过Pass直接设置uniform完成参数的传递。

9.效果演示

一顿操作猛如虎,通过上述的操作,我们成功去掉了MaskGraphics画线,DC也成功降了下来:

虽然DC已经是之前的四分之一,但是拼图碎片之间为什么没有合批?

材质不同

根据合批的要求,需要相同的材质和纹理,从上面来看,虽然我们用的是同一张纹理,但是材质是单个碎片单独new出来的:

因此首要的第一步,我们需要把材质改成同一个。

1.共享材质

声明一个静态的唯一的材质sharedMaterial

2.提取公共常量

公共的,所有碎片都一致的变量,还是按照原方式传递,不会打断合批。

3.特殊部分

对于每个碎片的边和圆角标志,因为它们都不相同,如果使用同一个材质,通过原方式传递,会导致所有的碎片的边框一样。

这里我们借助sprite.color去传递这部分参数,不会打断合批。

4.color通道中解码数据

解码标志位,拿到我们传递的参数。

5.效果演示

拿到参数后,圆角和边框算法和原来保持一致,运行后即可看到我们的效果,DC成功降到了个位数。

完整效果

合批了合批了

结语

以上就是通过Shader实现能够合批的拼图游戏的实战,不知道我说明白没有。

一个拼图游戏都出了这么多期了,还有什么素材可以继续?

本文实战完整源码已加更到亿元Cocos小游戏实战合集(8/10),不占份数,已经拥有的小伙伴可以直接更新。


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

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

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

推荐文章:

亿元Cocos小游戏实战合集

俄罗斯方块谁不会做......啊?流沙版?

最近很火的一个拼图游戏,老板让我用Cocos3.8做一个...

老板说拼图游戏太卷了,让我用Cocos做个3d版本的...

敢不敢挑战用Cocos3.8复刻曾经很火的割绳子游戏?

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

权限陷阱:为什么你的“点击复制”在某些浏览器或 iframe 里会失效?

2026年3月2日 07:36

本地开发环境 localhost 下复制按钮跑得飞起,一上线到测试环境(或者嵌套在公司的微前端子应用里)就变成了**“哑炮”**——点击没反应,控制台甚至连个报错都没有。

这通常不是代码逻辑问题,而是触碰了现代浏览器为了防范隐私窃取而设置的**“权限围栏”**。在 AI Prompt Manager 这种高频交互场景下,踩中这些坑非常影响职业信誉。


1. 第一大坑:Secure Context(安全上下文)

这是最容易被忽视的硬性红线。现代 navigator.clipboard API 仅在安全上下文中可用。

  • 陷阱:如果你公司的内部测试环境还在用 http://192.168.x.x 这种非加密协议,navigator.clipboard 直接就是 undefined
  • 真相:浏览器认为剪贴板含有高价值隐私,非 HTTPS 环境严禁脚本触碰。
  • 例外localhost127.0.0.1 被浏览器豁免,视为安全环境,这也是为什么“本地行,线上不行”的头号原因。

2. 第二大坑:Iframe 的“权限隔离”

如果你的 AI 工具是嵌入在另一个系统(如飞书、企业微信工作台、或微前端基座)的 iframe 里的,复制大概率会失败。

  • 原理:浏览器对 iframe 默认是不开启剪贴板权限的。
  • 破解方案:父页面必须显式在 iframe 标签上开启权限策略:

HTML

<iframe 
  src="your-ai-tool-url" 
  allow="clipboard-read; clipboard-write"
></iframe>

老兵提醒:如果你无法控制父页面的 HTML(比如三方平台),那么现代 API 这条路就彻底堵死了,必须考虑传统的 document.execCommand 降级方案。


3. 第三大坑:消失的“用户手势” (User Gesture)

剪贴板操作必须由**用户交互(如点击)**直接触发。

  • 陷阱:你可能想在 AI 接口返回结果后“自动帮用户复制”。
  • 逻辑fetch().then(() => navigator.clipboard.write(...))
  • 结局失败。因为在 then 回调执行时,浏览器认为最初的点击事件已经结束,当前的执行栈已经失去了“用户手势”的加持。
  • Safari 特供坑:Safari 极其严格。如果你在点击后执行了过于复杂的逻辑(超过 1 秒才去调用剪贴板 API),它也会认为手势失效。

4. 权限陷阱排查表

触发因素 现代 API (navigator.clipboard) 传统 API (execCommand)
HTTPS 要求 强制要求 不强制(但逐步收紧)
Iframe 支持 allow 属性授权 只要容器能 focus 即可
异步复制 支持 Promise,但手势判定严苛 不支持异步
权限弹窗 读取时触发,写入通常静默 无需权限弹窗

5. “防爆”代码模版

作为资深开发,我们不玩赌博。我们要写一个高鲁棒性的复制函数,自动处理权限和降级。

JavaScript

async function safeCopy(text) {
  // 1. 尝试使用现代 API
  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (err) {
      console.warn("现代 API 写入失败,尝试降级", err);
    }
  }

  // 2. 降级到传统 textarea 方案 (兼容 HTTP 和某些 Iframe)
  const textArea = document.createElement("textarea");
  textArea.value = text;
  
  // 隐藏元素,但不能用 display: none(否则无法 focus)
  textArea.style.position = "fixed";
  textArea.style.left = "-9999px";
  textArea.style.top = "0";
  document.body.appendChild(textArea);
  
  textArea.focus();
  textArea.select();
  
  try {
    const successful = document.execCommand('copy');
    document.body.removeChild(textArea);
    return successful;
  } catch (err) {
    document.body.removeChild(textArea);
    return false;
  }
}

6. 进阶提示:主动查询权限

如果你想在用户点击前就知道“复制按钮”是否能用,可以使用 Permissions API

JavaScript

const queryClipboardPermission = async () => {
  try {
    const result = await navigator.permissions.query({ name: "clipboard-write" });
    if (result.state === "granted" || result.state === "prompt") {
      // 权限可用
    }
  } catch (e) {
    // 某些浏览器不支持查询此权限
  }
};

Clipboard API 深度实战:如何同时存入“纯文本”和“富文本”两种格式?

2026年3月2日 07:36

用户复制了你 AI 助手生成的代码块,粘贴到 VS Code 里很完美,但粘贴到 Word 或飞书里时,背景颜色和字体格式全丢了。

传统的 document.execCommand('copy') 只能勉强处理简单的文本。要实现“一次复制,多处适配”,必须动用现代的 Clipboard API。它的核心逻辑是构建一个包含多种 MIME 类型的 ClipboardItem


1. 核心原理:MIME 类型多路复用

当你向系统剪贴板写入数据时,剪贴板本质上是一个 “键值对容器” 。你可以同时塞入 text/plain(保底)、text/html(带格式)甚至 image/png。目标软件在执行“粘贴”时,会根据自身能力选择最合适的格式。


2. 代码实现:封装一个“万能复制”器

现代 API 要求数据必须以 Blob 形式存在。注意:navigator.clipboard.write 接受的是一个数组,而数组里的每个 ClipboardItem 又是一个键值对对象。

JavaScript

async function copyRichText(plainText, htmlContent) {
  // 1. 权限校验
  if (!navigator.clipboard) {
    console.error("当前环境不支持 Clipboard API");
    return;
  }

  try {
    // 2. 将字符串包装为指定类型的 Blob
    const plainBlob = new Blob([plainText], { type: 'text/plain' });
    const htmlBlob = new Blob([htmlContent], { type: 'text/html' });

    // 3. 构建多格式 ClipboardItem
    const item = new ClipboardItem({
      'text/plain': plainBlob,
      'text/html': htmlBlob
    });

    // 4. 执行写入
    await navigator.clipboard.write([item]);
    
    console.log("多格式数据已写入剪贴板");
  } catch (err) {
    console.error("复制失败:", err);
  }
}

// 调用示例:
copyRichText(
  "const a = 1;", 
  "<pre style='color:red; background:#f0f0f0;'><code>const a = 1;</code></pre>"
);

3. 踩坑总结

① Safari 的“必须由用户手势触发”限制

Safari 对安全性的要求极其严苛。如果你在异步请求(如 fetch)的回调里执行复制,Safari 会因为“丢失用户手势上下文”而直接报错。

  • 对策:在 AI 场景下,不要等全文生成完才允许复制。或者在点击事件里立即创建一个 ClipboardItem 占位,等异步数据回来后再 resolve 它。

② HTML 的样式隔离问题

粘贴到邮件客户端或 Office 时,外部 CSS 文件是不生效的。

  • 对策:所有的富文本 HTML 必须使用 Inline Style(行内样式) 。如果你用的是 markdown-it,可以配合一个简单的正则将类名替换为具体的样式字符串。

③ 权限静默失败

在某些 iframe(如低版本的微前端环境)或者非 HTTPS 环境下,navigator.clipboardundefined

  • 对策:始终提供降级方案。如果新 API 不可用,回退到创建一个不可见的 textarea 执行 execCommand('copy')

4. 方案对比:为什么不建议用老方法?

维度 execCommand('copy') Clipboard API (现代)
异步支持 极差(必须同步执行) 原生 Promise 支持
多格式存取 几乎不可能(只能存单一格式) 完美支持(Plain/HTML/Image)
主线程影响 可能会引起布局抖动(Layout Thrashing) 全异步处理,不阻塞 UI
安全性 较低(脚本可静默嗅探) 严格权限控制(Permissions API)

5. 高阶应用:剪贴板脱敏

在处理敏感数据(如银行账号)时,你可以利用这个 API 做一层**“视觉欺骗”**:

  • 用户选中的是:6222 0210 0000 1234
  • 你存入 text/plain 的是:6222 0210 **** 1234
  • 你存入 text/html 的是:带有红色星号样式的脱敏文本。

这种策略既保护了数据隐私,又保留了良好的用户提示体验。


昨天 — 2026年3月1日掘金 前端

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

2026年3月1日 22:17

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

2026年3月1日 22:04

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

翻译:TUARAN

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

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

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

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

为什么 HTMX 适合 React + Next.js

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

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

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

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

前置条件

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

第 1 步:创建 Next.js 项目

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

第 2 步:在 Layout 中加载 HTMX

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

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

import Script from "next/script";

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

作者给出的理由是:

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

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

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

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

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

在 React Data Grid 中实现实时更新

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

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

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

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

核心思路:

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

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

创建 Data Grid(React + HTMX)

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

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

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

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

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

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

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

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

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

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

创建 SSE 端点

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

import { NextResponse } from "next/server";

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

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

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

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

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

最终效果(原文动图):

GitHub 参考

示例代码仓库:

常见问题(FAQ)

为什么要把 HTMX 和 React 混用?

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

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

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

适合这些情况:

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

结语

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

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

2026年3月1日 22:02

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

翻译:TUARAN

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

wechat_2026-03-01_220216_455.png

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

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

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

隐式资源管理(Implicit resource management)

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

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

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

const theWeakSet = new WeakSet([true]);

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

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

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

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

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

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

显式资源管理(Explicit resource management)

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

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

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

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

一个简化示例:

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

const generatorObject = generatorFunction();

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

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

console.log(generatorObject.return());

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

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

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

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

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

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

using:声明式资源管理

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

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

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

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

一个最小示例:

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

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

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

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

{
using theObject = {};
}

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

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

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

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

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

class TheClass {
theFile;

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

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

const theFile = "./some-file";

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

现状与落地

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

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

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

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

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

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

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

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

描述

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

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

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

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

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

端口

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

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

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

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

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

控件

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

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

Material 下拉选单详解

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

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

Range 滑动条

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

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

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

IOR 滑动条

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

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

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

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

生成的代码示例

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

Common 材质模式

HLSL

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

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

RustedMetal 材质模式

HLSL

float _DielectricSpecular_Out = 0.030;

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

Water 材质模式

HLSL

float _DielectricSpecular_Out = 0.020;

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

Ice 材质模式

HLSL

float _DielectricSpecular_Out = 0.018;

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

Glass 材质模式

HLSL

float _DielectricSpecular_Out = 0.040;

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

Custom 材质模式

HLSL

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

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

实际应用示例

塑料材质创建

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

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

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

水体渲染

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

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

自定义光学材质

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

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

技术细节与最佳实践

颜色空间考虑

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

性能影响

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

与其他节点的配合

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

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

常见误区

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

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

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

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

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

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

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

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

—— 《七言绝句》灵感~

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

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

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

何为“搜索”

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

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

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

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

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

二分查找

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

通过一道题感受一下:

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

6f97b261bdfc030c95deec4b34551bc2.jpg

解题思路:

1、拟定初始索引区间

2、计算中点索引

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

4、重复以上3步

代码实现:

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

优点与局限

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

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

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

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

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

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

暴力搜索

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

1、线性搜索

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

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

2、优先搜索

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

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

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

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

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

自适应搜索

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

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

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

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

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

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

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

方案选择

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

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

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

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

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

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

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

小结

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

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

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

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

2026年3月1日 19:46

前言

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

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

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


1. 什么是云端 API

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

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

云端 vs 本地

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

2. 案例

案例 1:主流 API 平台对比

国内平台

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

国际平台

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

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

Step 1:申请 API Key

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

Step 2:了解支持的模型

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

Step 3:API 调用示例

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

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

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

为什么选择 DeepSeek?

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

调用示例

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

案例 4:流式响应实现

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

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

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

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

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

案例 5:参数调优

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

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

参数说明

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

案例 6:成本估算与优化

成本计算

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

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

每月成本(30天):¥3.24

节省成本技巧

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

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

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

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

3. 总结

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

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

2026年3月1日 18:51

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

本文作者:霁明

1. 引言

1.1 背景与动机

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

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

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

我们期望的用户体验是:

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

1.2 最终效果

实现后的效果如下:

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

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

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

2.1 Lexical 简介

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

2.2 主流富文本框架对比

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

2.3 选择 Lexical 的理由

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

2.4 AIWorks 使用的依赖

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

3. Lexical 核心概念速览

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

3.1 编辑器状态

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

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

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

关键点

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

3.2 节点体系

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

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

核心节点类型:

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

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

3.3 命令系统

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

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

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

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

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

命令优先级从高到低:

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

3.4 节点转换

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

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

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

3.5 插件架构

Lexical 采用组合式插件设计:

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

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

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

4. 整体架构设计

4.1 架构图

Lexical 变量输入编辑器.png

4.2 组件职责划分

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

4.3 数据流及渲染过程

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

5. 核心实现详解

5.1 自定义 VariableLabelNode

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

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

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

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

关键设计点:

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

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

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

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

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

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

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

工作流程:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

5.4 变量标签的可视化渲染

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

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

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

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

5.5 编辑器单行模式

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

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

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

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

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

  return null;
};

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

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

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

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

编辑器状态初始化:

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

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

编辑器状态同步:

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

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

6. 总结

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

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

这套方案适用于:

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

最后

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

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

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

引言

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

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

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


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

Vite:下一代前端构建工具

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

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

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

Tailwind CSS:原子化 CSS 的革命者

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

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

优势包括:

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

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

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

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

安装命令:

pnpm add lucide-react

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

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

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

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

步骤 2:安装依赖

npm install tailwindcss @tailwindcss/vite

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

步骤 3:配置 Vite

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

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

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

步骤 4:引入 Tailwind CSS

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

@import "tailwindcss";

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

步骤 5:启动开发服务器

npm run dev

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


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

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

核心状态定义

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

通用事件处理器

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

密码可见性切换

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

加载状态(预留)

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

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


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

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

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

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

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

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


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

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

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


3. 顶部图标与标题

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

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


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

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

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


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

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

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

定位系统

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

交互状态

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

输入框自身样式

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

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

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

👁️ 6. 密码可见性切换

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

7. “忘记密码?”链接

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

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

虽然代码中使用了:

placeholder:text-slate-400

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

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

focus:placeholder:text-indigo-500

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

📚 伪类 vs 伪元素

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

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


第七部分:总结与展望

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

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

下一步你可以做什么?

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

结语

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

正如 Tailwind 官方所说:

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

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

Happy coding! 🚀

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

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

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

执行上下文

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

执行上下文的类型

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

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

执行栈.png

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

执行上下文的结构

执行上下文结构.png

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

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

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

创建和执行步骤

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

以上面的代码块为例

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

执行过程.png

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

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

这也就引出了作用域链

作用域

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

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

作用域的类型

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

我们再举一个例子来说明

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

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

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

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

作用域链体现.png

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

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

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

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

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

用以下代码块来说明:

let inIf = 'out of statement'

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

console.log(inIf)

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

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

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

var liList = []

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

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

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

将上述代码改为:

var liList = []

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

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

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

闭包

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

闭包的主要用途

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

闭包的注意事项

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