普通视图

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

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

作者 SmalBox
2026年1月25日 18:03

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

在Unity的Shader Graph中,NormalVector节点是一个基础且重要的工具,它允许着色器访问网格的法线矢量信息。法线矢量在计算机图形学中扮演着关键角色,它定义了表面的朝向,是光照计算、材质表现和各种视觉效果的基础。

节点概述

NormalVector节点为着色器编写者提供了获取网格法线数据的便捷途径。无论是顶点法线还是片元法线,这个节点都能让开发者轻松地在不同的坐标空间中操作这些数据。通过简单的参数设置,就可以将法线矢量转换到所需的坐标空间,大大简化了复杂着色器的开发过程。

法线矢量的本质是垂直于表面的单位向量,在三维空间中表示为(x, y, z)坐标。在Shader Graph中,这些数据通常来自3D模型的顶点数据,或者通过法线贴图等技术进行修改和增强。

参数详解

Space参数

Space参数决定了法线矢量输出的坐标空间,这是NormalVector节点最核心的功能。不同的坐标空间适用于不同的着色场景和计算需求。

  • Object空间:也称为模型空间,这是法线数据最原始的存储空间。在Object空间中,法线相对于模型本身的坐标系定义,不考虑模型的旋转、缩放或平移变换。当模型发生变换时,Object空间中的法线不会自动更新,需要手动进行相应的变换计算。
  • View空间:也称为相机空间或眼睛空间,在这个空间中,所有坐标都是相对于相机的位置和方向定义的。View空间的原点通常是相机的位置,Z轴指向相机的观察方向。这个空间特别适合与视角相关的效果,如边缘光、反射和折射。
  • World空间:World空间中的坐标是相对于场景的世界坐标系定义的。无论模型如何移动或旋转,World空间提供了统一的参考框架。这个空间常用于光照计算、阴影生成和全局效果。
  • Tangent空间:这是一个特殊的局部空间,主要用于法线贴图。在Tangent空间中,法线是相对于表面本身定义的,Z轴与表面法线对齐,X轴与切向量对齐,Y轴与副法线对齐。这种表示方法使得法线贴图可以在不同朝向的表面上重复使用。

选择正确的坐标空间对着色器的正确性和性能至关重要。错误的空间选择可能导致光照计算错误、视觉效果异常或性能下降。

端口信息

NormalVector节点只有一个输出端口:

  • Out:输出类型为Vector 3,表示三维矢量。这个端口输出的是根据Space参数选择在对应坐标空间中的法线矢量。输出值通常是归一化的单位矢量,但在某些情况下(如使用非统一缩放时)可能需要重新归一化。

使用场景与示例

基础光照计算

法线矢量的一个主要应用是光照计算。在Lambert光照模型中,表面亮度取决于光线方向与表面法线之间的夹角。

HLSL

// 简化的Lambert光照计算
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
float3 worldNormal = NormalVector节点输出(World空间);
float NdotL = max(0, dot(worldNormal, lightDir));
float3 diffuse = _LightColor0 * NdotL;

在这个示例中,我们首先获取世界空间中的法线矢量和光线方向,然后计算它们的点积。点积结果决定了表面接收到的光照强度,这是大多数基础光照模型的核心计算。

法线贴图应用

法线贴图是现代实时渲染中增强表面细节的关键技术。NormalVector节点在应用法线贴图时起着桥梁作用。

HLSL

// 法线贴图应用流程
float3 tangentNormal = tex2D(_NormalMap, uv).xyz * 2 - 1; // 从[0,1]转换到[-1,1]
float3 worldNormal = NormalVector节点输出(World空间);
// 使用TBN矩阵将切线空间法线转换到世界空间
float3x3 TBN = float3x3(
    IN.tangent.xyz,
    cross(IN.normal, IN.tangent.xyz) * IN.tangent.w,
    IN.normal
);
float3 mappedNormal = mul(TBN, tangentNormal);

这个示例展示了如何将切线空间中的法线贴图数据转换到世界空间。首先从法线贴图中采样并调整数值范围,然后使用TBN(切线-副切线-法线)矩阵进行空间转换。

边缘检测与轮廓光

利用View空间中的法线可以创建各种与视角相关的效果,如边缘光和轮廓检测。

HLSL

// 边缘光效果
float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_V, NormalVector节点输出(World空间)));
float3 viewDir = normalize(UnityWorldToViewPos(IN.worldPos));
float rim = 1 - abs(dot(viewNormal, viewDir));
float rimLight = pow(rim, _RimPower) * _RimIntensity;

在这个示例中,我们首先将世界空间法线转换到View空间,然后计算法线与视角方向的点积。当表面几乎垂直于视角方向时(即边缘处),点积接近0,从而产生边缘光效果。

环境遮挡与全局光照

法线信息对于环境遮挡和全局光照计算也至关重要。

HLSL

// 简化的环境遮挡
float3 worldNormal = NormalVector节点输出(World空间);
float ambientOcclusion = 1.0;

// 基于法线方向的简单环境光遮蔽
// 这里可以使用更复杂的算法,如SSAO或烘焙的AO贴图
ambientOcclusion *= (worldNormal.y * 0.5 + 0.5); // 模拟顶部光照更多

// 应用环境光
float3 ambient = UNITY_LIGHTMODEL_AMBIENT * ambientOcclusion;

这个简单的示例展示了如何用法线方向来模拟环境光遮蔽效果。在实际项目中,通常会结合更复杂的算法或预计算的数据。

高级应用技巧

法线重定向与混合

在某些情况下,需要将法线从一个表面重定向到另一个表面,或者在不同法线源之间进行混合。

HLSL

// 法线混合示例
float3 normalA = tex2D(_NormalMapA, uv).xyz;
float3 normalB = tex2D(_NormalMapB, uv).xyz;
float blendFactor = _BlendFactor;

// 使用线性插值混合法线
float3 blendedNormal = lerp(normalA, normalB, blendFactor);

// 或者使用更精确的球面线性插值
// float3 blendedNormal = normalize(lerp(normalA, normalB, blendFactor));

法线混合是一个复杂的话题,因为简单的线性插值可能不会保持法线的单位长度。在实际应用中,可能需要重新归一化或使用更高级的插值方法。

法线空间转换优化

在性能关键的场景中,法线空间转换可能需要优化。

HLSL

// 优化的世界空间法线计算
// 传统方法
float3 worldNormal = normalize(mul(IN.normal, (float3x3)unity_WorldToObject));

// 优化方法 - 使用逆转置矩阵(处理非统一缩放)
float3 worldNormal = normalize(mul(transpose((float3x3)unity_WorldToObject), IN.normal));

当模型应用了非统一缩放时,直接使用模型矩阵变换法线会导致错误的结果。在这种情况下,需要使用模型矩阵的逆转置矩阵来正确变换法线。

法线可视化与调试

在开发过程中,可视化法线矢量对于调试着色器非常有用。

HLSL

// 法线可视化
float3 worldNormal = NormalVector节点输出(World空间);
// 将法线从[-1,1]范围映射到[0,1]范围以便可视化
float3 normalColor = worldNormal * 0.5 + 0.5;
return float4(normalColor, 1.0);

这个简单的着色器将法线矢量的各个分量映射到颜色通道,从而可以直观地查看法线的方向和分布。

常见问题与解决方案

法线不连续问题

当使用低多边形模型或不当的UV展开时,可能会遇到法线不连续的问题。

  • 问题表现:表面出现不自然的硬边或接缝
  • 解决方案
    • 确保模型有适当的平滑组设置
    • 检查UV展开是否导致法线贴图采样错误
    • 考虑使用更高精度的模型或细分表面

性能考量

法线计算可能会成为性能瓶颈,特别是在移动设备或复杂场景中。

  • 优化策略
    • 在顶点着色器中计算法线,而不是片元着色器
    • 使用更简单的法线计算,如省略归一化步骤(如果对视觉效果影响不大)
    • 考虑使用法线贴图的压缩格式以减少内存带宽

法线精度问题

在特定情况下,法线计算可能会遇到精度问题,导致视觉瑕疵。

  • 问题表现:闪烁的表面、带状伪影或不准确的光照
  • 解决方案
    • 使用更高精度的数据类型(如half改为float)
    • 确保法线贴图使用适当的格式和压缩
    • 检查法线变换矩阵的精度和正确性

与其他节点的配合使用

NormalVector节点很少单独使用,通常与其他Shader Graph节点结合以实现复杂的效果。

  • 与Dot Product节点结合:用于计算光照强度、菲涅尔效应等
  • 与Transform节点结合:在不同坐标空间之间转换法线
  • 与Normalize节点结合:确保法线保持单位长度
  • 与Sample Texture 2D节点结合:应用法线贴图
  • 与Fresnel Effect节点结合:创建基于视角的效果

最佳实践

为了确保NormalVector节点的正确使用和最佳性能,建议遵循以下最佳实践:

  • 始终考虑法线是否需要归一化,特别是在进行数学运算或空间变换后
  • 选择最适合当前计算任务的坐标空间,避免不必要的空间转换
  • 在性能敏感的场景中,尽可能在顶点着色器中计算法线相关数据
  • 使用适当的数据类型平衡精度和性能
  • 定期验证法线计算的正确性,特别是在使用复杂变换或混合时

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

JavaScript 函数入门:从基础调用到闭包与模块化

2026年1月25日 18:02

在 JavaScript 的世界里,函数不仅是代码的执行单元,更是构建复杂应用的核心积木。正如你的笔记所言:“函数包含一组语句,它们是 JavaScript 的基础模块单元,用于代码复用、信息隐藏和组合调用。” 本文将系统梳理 JavaScript 函数的核心特性——从其对象本质、四种调用模式,到闭包、模块化等高级概念,助你真正掌握这门语言的灵魂。


一、函数即对象:一切皆可赋值

JavaScript 中最颠覆传统编程认知的一点是:函数是头等对象(First-class Object)

function add(a, b) {
    return a + b;
  }
  
  // 函数可以像普通变量一样被赋值、传递、存储
  var myFunc = add;
  console.log(myFunc(2, 3)); // 5
  
  // 甚至可以作为对象属性(方法)
  var calculator = { operate: add };

✅ 函数对象的特殊性:

  • 它拥有普通对象的所有能力(可添加属性、可作为参数传递);
  • 唯一区别:它可以通过 () 被调用(invoked)
  • 其原型链为:add → Function.prototype → Object.prototype

💡 正因函数是对象,我们才能实现回调、高阶函数、闭包等强大模式。


二、函数字面量:声明的四种方式

最常用的是函数字面量(Function Literal)

// 命名函数(推荐,便于调试)
function greet(name) {
    return "Hello, " + name;
  }
  
  // 匿名函数(常用于回调)
  setTimeout(function() {
    console.log("Delayed!");
  }, 1000);

此外还有:

  • 函数表达式const fn = function() {}
  • 箭头函数(ES6+)const fn = () => {}

📌 命名建议:除非作为简短回调,否则优先使用命名函数,提升堆栈可读性。


三、四大调用模式:this 的命运由谁决定?

函数调用时,会自动获得两个“免费”参数:this 和 arguments。而 this 的指向,取决于调用方式

1. 方法调用模式(Method Invocation)

var obj = {
    name: "Alice",
    sayHi: function() {
      console.log(this.name); // "Alice" —— this 指向 obj
    }
  };
  obj.sayHi();

✅ this 绑定到调用对象,这是面向对象编程的基础。

2. 函数调用模式(Function Invocation)

function sayName() {
    console.log(this); // 非严格模式:window;严格模式:undefined
  }
  sayName(); // 直接调用

⚠️ 危险!  在非严格模式下,this 意外指向全局对象,易引发 bug。

3. 构造器调用模式(Constructor Invocation)

function Person(name) {
    this.name = name; // this 指向新创建的实例
  }
  var p = new Person("Bob");

✅ 使用 new 时:

  • 创建新对象;
  • this 绑定到该对象;
  • 若无显式 return 对象,则返回 this

4. Apply/Call 调用模式(Explicit Invocation)

function introduce() {
    console.log("I'm " + this.name);
  }
  
  var user = { name: "Carol" };
  introduce.call(user);   // "I'm Carol"
  introduce.apply(user);  // 同上(参数以数组形式传入)

✅ 显式指定 this,是实现函数借用、绑定上下文的关键。

🌟 现代替代:ES5+ 的 bind() 可创建永久绑定 this 的新函数。


四、参数与返回:灵活但需谨慎

参数:arguments 对象

function sum() {
    let total = 0;
    for (let i = 0; i < arguments.length; i++) {
      total += arguments[i];
    }
    return total;
  }
  sum(1, 2, 3); // 6
  • arguments 是类数组对象,无 map/forEach 等方法;
  • 现代替代:使用 Rest 参数(...args  获取真数组:
function sum(...numbers) {
    return numbers.reduce((a, b) => a + b, 0);
  }

返回值规则

  • 无 return → 返回 undefined
  • 构造函数中若 return 非对象 → 忽略,仍返回 this
  • 若 return 对象 → 返回该对象(覆盖 this)。

五、闭包:函数的“记忆”能力

闭包 = 内部函数 + 外部作用域的引用

function counter() {
    var count = 0;
    return function() {
      count++;
      return count;
    };
  }
  
  var c = counter();
  console.log(c()); // 1
  console.log(c()); // 2 —— count 被“记住”了!

✅ 闭包的价值:

  • 数据私有化count 外部无法直接访问;
  • 状态保持:函数“记住”了创建时的环境;
  • 模块化基础:实现信息隐藏。

六、模块模式:告别全局污染

利用闭包,可构建模块(Module) ——提供接口但隐藏内部状态:

var MyModule = (function() {
    var privateVar = "secret";
  
    function privateMethod() {
      console.log(privateVar);
    }
  
    return {
      publicMethod: function() {
        privateMethod();
      }
    };
  })();
  
  MyModule.publicMethod(); // "secret"
  // MyModule.privateVar → undefined(无法访问)

✅ 优势

  • 避免全局变量冲突;
  • 实现封装与解耦;
  • 是现代 ES6 模块(import/export)的思想前身。

七、高级技巧:记忆化、套用与级联

1. 记忆化(Memoization)

缓存计算结果,避免重复运算:

function fibonacci(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n <= 1) return n;
    memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
    return memo[n];
  }

2. 套用(Currying)

将多参数函数转换为一系列单参数函数:

function add(a) {
    return function(b) {
      return a + b;
    };
  }
  var add5 = add(5);
  add5(3); // 8

3. 级联(Chaining)

方法返回对象自身,支持链式调用:

var obj = {
    value: 0,
    add: function(x) {
      this.value += x;
      return this; // 关键:返回 this
    },
    log: function() {
      console.log(this.value);
      return this;
    }
  };
  
  obj.add(2).add(3).log(); // 5

八、异常处理:优雅应对错误

try {
    throw new Error("Something went wrong!");
  } catch (e) {
    console.error(e.message);
  } finally {
    console.log("Cleanup");
  }
  • throw 抛出异常对象(建议用 Error 实例);
  • catch 捕获并处理;
  • finally 无论是否出错都会执行。

结语:函数是 JavaScript 的灵魂

从简单的代码复用,到复杂的闭包、模块、高阶函数,JavaScript 的函数机制赋予了开发者极大的表达力。理解其对象本质、this 绑定规则、作用域链与闭包原理,是写出健壮、可维护代码的前提。

正如 Douglas Crockford 所言: “JavaScript 的精华,就在于它的函数。”  掌握函数,你就掌握了这门语言的钥匙。

Server Components vs Client Components:Next.js 开发者的选择指南

作者 北辰alk
2026年1月25日 17:51

Server Components vs Client Components:Next.js 开发者的选择指南

在 Next.js 的世界里,理解这两种组件的区别,就像掌握武术中的“刚柔并济”

大家好!今天我们来深入探讨 Next.js 13+ 中最重要的架构变革:Server Components(服务端组件)Client Components(客户端组件)。这两者的选择不仅影响性能,更关乎应用架构的根本决策。

📊 快速对比:一图看懂核心差异

先来个直观对比,让大家有个整体概念:

特性维度 Server Components Client Components
渲染位置 服务端 客户端
Bundle大小 零打包,不发送到客户端 需要打包并发送到客户端
数据获取 直接访问数据库/API 通过API端点获取
交互性 无(纯展示) 完全交互式
生命周期 无(每次请求重新渲染) 完整React生命周期
DOM API 不可用 完全可用
状态管理 无状态 useState、useReducer等
第三方库 需兼容服务端渲染 无限制

🔍 深入解析:它们到底做了什么?

Server Components:服务端的“魔法”

// app/products/page.js - 默认就是Server Component
import { db } from '@/lib/db'

// 服务端组件可以直接访问数据库!
export default async function ProductsPage() {
  // 直接读取数据库,不需要API路由
  const products = await db.products.findMany({
    where: { isPublished: true }
  })
  
  return (
    <div>
      <h1>产品列表</h1>
      {/* 数据直接嵌入HTML,对SEO友好 */}
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.description}</p>
            {/* 注意:这里不能有事件处理器 */}
          </li>
        ))}
      </ul>
    </div>
  )
}

Server Components的优势:

  • 零客户端Bundle:代码永远不会发送到浏览器
  • 直接数据访问:减少客户端-服务器往返
  • 自动代码分割:只发送当前路由需要的代码
  • 敏感信息安全:API密钥、数据库凭证安全保留在服务端

Client Components:客户端的“灵魂”

'use client' // 这个指令至关重要!

import { useState, useEffect } from 'react'
import { addToCart } from '@/actions/cart'
import { LikeButton } from './LikeButton'

export default function ProductCard({ initialProduct }) {
  const [product, setProduct] = useState(initialProduct)
  const [isLiked, setIsLiked] = useState(false)
  
  // 客户端特有的生命周期
  useEffect(() => {
    // 可以访问浏览器API
    const viewed = localStorage.getItem(`viewed_${product.id}`)
    if (!viewed) {
      localStorage.setItem(`viewed_${product.id}`, 'true')
      // 发送浏览记录到分析服务
      analytics.track('product_view', { id: product.id })
    }
  }, [product.id])
  
  // 交互事件处理
  const handleAddToCart = async () => {
    await addToCart(product.id)
    // 显示动画反馈
    // 更新购物车图标数量
  }
  
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price}元</p>
      
      {/* 交互式组件 */}
      <button 
        onClick={handleAddToCart}
        className="add-to-cart-btn"
      >
        加入购物车
      </button>
      
      {/* 使用第三方UI库 */}
      <LikeButton 
        isLiked={isLiked}
        onChange={setIsLiked}
      />
      
      {/* 使用状态驱动的UI */}
      <div className={`stock-status ${product.stock < 10 ? 'low' : ''}`}>
        库存: {product.stock}
      </div>
    </div>
  )
}

🎯 黄金选择法则:什么时候用什么?

默认选择 Server Component 当:

  • ✅ 纯数据展示,无需交互
  • ✅ 访问后端资源(数据库、文件系统)
  • ✅ 需要减少客户端JavaScript体积
  • ✅ 包含敏感逻辑或数据
  • ✅ SEO是关键考虑因素
  • ✅ 内容基本静态,变化不频繁
// ✅ 应该用 Server Component
// 博客文章页面
export default async function BlogPost({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } })
  const relatedPosts = await db.posts.findMany({
    where: { category: post.category },
    take: 3
  })
  
  return <Article content={post.content} related={relatedPosts} />
}

必须使用 Client Component 当:

  • ✅ 需要用户交互(点击、输入、拖拽)
  • ✅ 使用浏览器API(localStorage、geolocation)
  • ✅ 需要状态管理(useState、useReducer)
  • ✅ 使用第三方交互式库(地图、图表、富文本编辑器)
  • ✅ 需要生命周期效果(useEffect)
  • ✅ 实现动画或过渡效果
'use client'
// ✅ 必须用 Client Component
// 实时搜索组件
export default function SearchBox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isSearching, setIsSearching] = useState(false)
  
  // 防抖搜索
  useEffect(() => {
    if (!query.trim()) return
    
    const timer = setTimeout(async () => {
      setIsSearching(true)
      const res = await fetch(`/api/search?q=${query}`)
      const data = await res.json()
      setResults(data)
      setIsSearching(false)
    }, 300)
    
    return () => clearTimeout(timer)
  }, [query])
  
  return (
    <div>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {isSearching && <Spinner />}
      <SearchResults results={results} />
    </div>
  )
}

🛠️ 混合使用:现实世界的案例

真正的应用往往是混合使用的,下面看一个电商产品页面的例子:

// app/product/[id]/page.js - Server Component
import { db } from '@/lib/db'
import ProductDetails from './ProductDetails' // Client Component
import ProductReviews from './ProductReviews' // Server Component
import AddToCartButton from '@/components/AddToCartButton' // Client Component

export default async function ProductPage({ params }) {
  // 服务端:获取核心数据
  const product = await db.products.findUnique({
    where: { id: params.id },
    include: { category: true }
  })
  
  // 服务端:获取评论(SEO重要)
  const reviews = await db.reviews.findMany({
    where: { productId: params.id, isVerified: true },
    take: 10
  })
  
  // 服务端:获取推荐(个性化)
  const recommendations = await getRecommendations(product.id)
  
  return (
    <div className="product-page">
      {/* 服务器组件传递数据到客户端组件 */}
      <ProductDetails 
        product={product} 
        // 客户端交互收藏分享放大图片
      />
      
      {/* 服务器组件:纯展示评论 */}
      <ProductReviews 
        reviews={reviews}
        // 客户端交互点赞回复评论嵌套的客户端组件)
      />
      
      {/* 客户端组件:购物车交互 */}
      <AddToCartButton 
        productId={product.id}
        stock={product.stock}
      />
      
      {/* 服务端组件:推荐列表 */}
      <RecommendationList 
        products={recommendations}
        // 每个推荐项内部可能有客户端交互
      />
    </div>
  )
}

💡 高级模式与最佳实践

1. 组件边界优化

// 不好的做法:整个页面都是客户端组件
'use client' // ❌ 不要轻易在顶层加这个

// 好的做法:精确控制客户端边界
export default function UserDashboard() {
  return (
    <div>
      {/* 服务端组件:用户信息(静态) */}
      <UserProfile />
      
      {/* 服务端组件:统计数据 */}
      <AnalyticsSummary />
      
      {/* 精确的客户端边界:交互式图表 */}
      <div className="interactive-section">
        <RealTimeChart />
        <FilterControls />
      </div>
    </div>
  )
}

2. 数据传递模式

// ✅ 模式:服务端获取数据,传递给客户端
// Server Component
export default async function Dashboard() {
  const initialData = await fetchDashboardData()
  
  return <InteractiveDashboard initialData={initialData} />
}

// Client Component
'use client'
function InteractiveDashboard({ initialData }) {
  const [data, setData] = useState(initialData)
  
  // 客户端更新数据
  const refreshData = async () => {
    const newData = await fetch('/api/dashboard')
    setData(newData)
  }
  
  return (
    <>
      <DashboardUI data={data} />
      <button onClick={refreshData}>刷新</button>
    </>
  )
}

3. 性能优化策略

// 策略:代码分割 + 懒加载客户端组件
import dynamic from 'next/dynamic'

// 重交互组件动态导入
const HeavyChart = dynamic(
  () => import('@/components/HeavyChart'),
  { 
    ssr: false, // 不在服务端渲染
    loading: () => <ChartSkeleton /> 
  }
)

export default function AnalyticsPage() {
  return (
    <div>
      <h1>数据分析</h1>
      {/* 这个组件只在客户端加载 */}
      <HeavyChart />
    </div>
  )
}

🚨 常见陷阱与解决方案

陷阱1:在Server Component中使用客户端特性

// ❌ 错误:在服务端组件中使用useState
export default function ServerComponent() {
  const [count, setCount] = useState(0) // 编译错误!
  return <div>{count}</div>
}

// ✅ 解决方案:提取为客户端组件
'use client'
function Counter() {
  const [count, setCount] = useState(0)
  return <div>{count}</div>
}

export default function Page() {
  return <Counter />
}

陷阱2:不必要的客户端边界

// ❌ 不必要的客户端标记
'use client'
export default function Page() {
  // 这个组件没有任何交互,却标记为客户端!
  return <div>静态内容</div>
}

// ✅ 保持为服务端组件
export default function Page() {
  return <div>静态内容</div>
}

陷阱3:过度嵌套导致的序列化问题

// ❌ 传递无法序列化的数据
export default async function Page() {
  const data = await fetchData()
  // 函数、Date对象等无法序列化
  return <ClientComponent data={data} callback={() => {}} />
}

// ✅ 仅传递可序列化数据
export default async function Page() {
  const data = await fetchData()
  // 清理数据,确保可序列化
  const serializableData = JSON.parse(JSON.stringify(data))
  return <ClientComponent data={serializableData} />
}

📈 性能影响:真实数据对比

根据Vercel的测试数据:

场景 纯客户端渲染 混合渲染(推荐) 纯服务端组件
首屏加载时间 2.8s 1.2s ⭐ 1.0s
可交互时间 2.8s 1.4s ⭐ N/A
Bundle大小 245KB 78KB ⭐ 12KB
SEO友好度 高 ⭐

结论:混合方案在绝大多数场景下是最佳选择!

🔮 未来趋势

  1. Partial Prerendering(部分预渲染):Next.js 14+ 的新特性,自动混合静态和动态内容
  2. Server Actions:更深度集成服务端逻辑
  3. Edge Runtime优化:组件级别的边缘计算部署

🎓 总结:决策流程图

这里给你一个快速决策流程图:

开始
  ↓
组件需要交互吗?
  ↓
是 → 需要浏览器API吗? → 是 → Client Component ✅
  ↓                ↓
否               否 → 有状态吗? → 是 → Client Component ✅
  ↓                ↓           ↓
Server Component ← 否 ← 否 ← 仅展示数据?
  ↓
需要考虑Bundle大小吗? → 是 → Server Component ✅
  ↓
否
↓
Client Component ✅

💬 互动讨论

话题讨论

  1. 你在项目中最大的 Server/Client Component 挑战是什么?
  2. 有没有遇到性能大幅提升的成功案例?
  3. 你如何向团队成员解释这两种组件的区别?

欢迎在评论区分享你的经验和见解!

promise-logic -- 声明式 Promise 逻辑组合

作者 xier123456
2026年1月25日 15:58

用逻辑概念替代 API 记忆
promise-logic 的设计是:开发者应专注于业务逻辑,而非 Promise API 的细节
传统 Promise 组合(如 Promise.allPromise.race)的命名与语义不够直观,尤其在复杂异步场景下,代码可读性迅速下降。
promise-logic 通过逻辑门(Logic Gate) 的方式,将异步组合抽象为 andorxor 等逻辑操作,使代码语义清晰、逻辑自解释。


相关功能

  1. 逻辑语义化

    • and:所有任务必须成功(等价于 Promise.all

    • or:至少一个任务成功(等价于 Promise.race

    • xor有且仅有一个任务成功

    • nand:所有任务均失败

    • not:反转单个 Promise 的结果

    • majority:多数任务成功

  2. 零依赖
    仅依赖原生 Promise,无额外运行时依赖。

  3. 全测试覆盖
    所有逻辑门均经过严格单元测试,确保行为符合预期。

  4. 错误分类明确

    • PromiseLogicError 统一错误类型
    • error.type 区分具体逻辑错误(如 'XOR_ERROR'

安装

npm install promise-logic

快速开始

示例:主备服务调用(XOR 场景)

import { PromiseLogic } from 'promise-logic';

// 主服务调用
const primary = fetch('https://api.main.com/data');
// 备用服务调用
const backup = fetch('https://api.backup.com/data');

// 执行 XOR 逻辑:有且仅有一个成功
PromiseLogic.xor([primary, backup])
  .then(result => {
    console.log('成功获取数据:', result);
  })
  .catch(error => {
    if (error.type === 'XOR_ERROR') {
      console.error('主备服务均成功或均失败,不符合 XOR 语义');
    } else {
      console.error('网络错误:', error);
    }
  });

示例:多数决决策(Majority 场景)

import { PromiseLogic } from 'promise-logic';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

PromiseLogic.majority(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

typescript类型断言场景

import { PromiseLogic } from 'promise-logic/typescript';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

//可以进行类型断言,也可以默认让PromiseLogic自动推断类型
PromiseLogic.majority<Response>(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

API 参考

API 说明
and 所有 Promise 成功,返回结果数组;任一失败则整体失败。
or 至少一个 Promise 成功,返回首个成功结果;全部失败则整体失败。
xor 有且仅有一个 Promise 成功,返回该结果;否则抛出 XOR_ERROR
nand 所有 Promise 均失败,返回错误数组;任一成功则整体失败。
not 反转单个 Promise 的结果
majority 超过半数 Promise 成功,返回成功结果数组;否则整体失败。

资源链接

pnpm+pnpm-workspace怎么关联本地包?

作者 胡一闻
2026年1月25日 15:55

一、最核心的问题先回答

为什么我只在 pnpm-workspace.yaml 里写了 apps/*
pnpm 就能知道 apps/webapps/api 这些包,并把它们用起来?

答案只有一句话:

因为 pnpm 在启动时,
先用 Glob 规则找目录 → 再识别哪些是真正的包 → 在内存里记住它们的 name 和路径


二、什么是 Glob?(一定要先懂这个)

1️⃣ Glob 是什么

Glob 是一种“用模式匹配文件路径”的规则

你每天其实都在用,比如:

ls *.js
ls apps/*

这里的 * 就是 Glob。


2️⃣ Glob 的“全称”是啥?

  • 没有严格官方全称
  • 约定俗成理解为:Global Pattern Matching
  • 起源于 Unix Shell

👉 它不是 pnpm 发明的,是整个操作系统层面的东西。


3️⃣ Glob 能干什么?

Glob 只做一件事

👉 在磁盘上找出“路径长得像”的文件或目录

⚠️ 重要:

  • Glob 不懂什么是包
  • Glob 不看 package.json
  • 它只认路径形状

三、apps/* 到底是什么意思?

packages:
  - apps/*

这句话的真实含义是:

“请在项目根目录下,
找出所有路径形状像 apps/某个名字 的目录。”

比如磁盘上有:

apps/web
apps/api
apps/docs

Glob 匹配结果就是这三个。


四、pnpm 是怎么一步步工作的?(重点)

第一步:pnpm 判断是不是 Workspace

pnpm 启动时先看:

有没有 pnpm-workspace.yaml

  • 有 → Workspace 模式
  • 没有 → 单包模式

⚠️ pnpm 只认这个文件


第二步:Glob 展开(找目录)

pnpm 读取:

packages:
  - apps/*

然后:

  • 使用 Glob 规则
  • 遍历文件系统
  • 找到所有匹配的目录:
apps/web
apps/api
apps/docs

⚠️ 此时 pnpm 还不知道谁是包


第三步:识别“真正的包”

pnpm 接下来会逐个检查:

  • apps/web → 有 package.json
  • apps/api → 有 package.json
  • apps/docs → 没有 ❌

只有package.json 的目录才算 workspace 包。


第四步:建立 Package Map(最关键)

pnpm 会读取每个包的 package.json 里的:

{
  "name": "@my-org/web",
  "version": "1.0.0"
}

然后在内存中建立一张表

@my-org/web  -> apps/web
@my-org/api  -> apps/api
@my-org/ui   -> packages/ui

👉 这一步非常重要:

  • pnpm 认的是 name
  • 不是目录名
  • 不是路径

五、pnpm 是什么时候把包“连起来”的?

❌ 不是扫描时

✅ 是安装依赖时

比如 apps/web/package.json

{
  "dependencies": {
    "@my-org/ui": "^1.0.0"
  }
}

pnpm 在安装时会想:

  1. 我要找 @my-org/ui
  2. Workspace 里有没有同名包?
  3. 有 → 用本地的
  4. 没有 → 去 npm 仓库下载

👉 所谓“连起来”,本质是:

把依赖指向本地 workspace 包,而不是远程包


六、如果没有 pnpm-workspace.yaml 会怎样?

pnpm 会:

  • ❌ 不扫描其他目录
  • ❌ 不建立包映射表
  • ❌ 不知道本地还有同名包

结果就是:

即使你本地有 packages/ui
pnpm 也会去 npm 仓库下载一个同名包。


七、mac 上怎么自己“看到” Glob 在干嘛?

macOS 默认用的是 zsh,它天生支持 Glob。

你可以直接在终端试:

# 看 Glob 匹配了哪些目录
ls apps/*

再试:

# 看哪些是真正的包
ls apps/*/package.json

👉 这两步,和 pnpm 内部做的事情几乎一模一样。


八、最容易踩的坑(小白必看)

❌ 错误写法

packages:
  - apps

只会尝试:

apps/package.json

✅ 正确写法

packages:
  - apps/*

九、终极一句话总结(背下来就够了)

Glob 只是用来找目录的规则。
pnpm 用 Glob 找到目录后,再通过 package.json 判断哪些是包,
并把它们的 name 和路径记在内存里。
真正把包“连起来”的,是后面的依赖解析,而不是 Glob 本身。


pnpm Workspace 全流程图

┌────────────────────────────┐
│ 你运行 pnpm install        │
└─────────────┬──────────────┘
              │
              ▼
┌────────────────────────────┐
│ ① 是否存在 pnpm-workspace   │
│    .yaml ?                 │
└─────────────┬──────────────┘
      有      │        没有
      ▼       │         ▼
┌──────────────────┐   ┌──────────────────┐
│ Workspace 模式    │   │ 单包模式          │
└────────┬─────────┘   │(不扫描别的包)     │
         │             └──────────────────┘
         ▼
┌────────────────────────────┐
│ ② 读取 pnpm-workspace.yaml │
│    packages:               │
│    - apps/*                │
│    - packages/*            │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ③ Glob 展开(找目录)        │
│ apps/* →                   │
│   apps/web                 │
│   apps/api                 │
│   apps/docs                │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ④ 判断是否是包              │
│   有没有 package.json ?     │
│   web  ✅                  │
│   api  ✅                  │
│   docs ❌                  │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑤ 读取 package.json        │
│   name / version           │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑥ 建立 Package Map(内存)   │
│   @my-org/web → apps/web   │
│   @my-org/api → apps/api   │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑦ 依赖解析(install 阶段)   │
│ web 依赖 @my-org/ui ?       │
│ → workspace 里有吗?         │
│ → 有 → 用本地包              │
│ → 没有 → 去 npm 仓库         │
└────────────────────────────┘

Vue 3中watch如何高效监听多数据源、计算结果与数组变化?

作者 kknone
2026年1月25日 15:48

多数据源监听

在Vue 3中,watch 允许我们同时监听多个响应式数据源,当其中任意一个数据源发生变化时,都会触发回调函数。这在需要同步处理多个数据变化的场景中非常实用,比如表单多字段联动验证、多条件组合筛选等。

基本用法

我们可以将多个数据源(ref、reactive对象或getter函数)放入一个数组中,作为watch的第一个参数。回调函数的第一个参数是所有数据源的新值组成的数组,第二个参数是旧值组成的数组。

import { ref, watch } from 'vue'

// 定义多个响应式数据
const username = ref('')
const password = ref('')
const rememberMe = ref(false)

// 同时监听三个数据源
watch(
  [username, password, rememberMe],
  ([newUsername, newPassword, newRememberMe], [oldUsername, oldPassword, oldRememberMe]) => {
    console.log(`用户名从 ${oldUsername} 变为 ${newUsername}`)
    console.log(`密码从 ${oldPassword} 变为 ${newPassword}`)
    console.log(`记住我状态从 ${oldRememberMe} 变为 ${newRememberMe}`)
    
    // 实际场景中可以在这里进行表单验证
    if (newUsername && newPassword) {
      console.log('表单字段已填写完整')
    }
  }
)

执行流程

flowchart LR
A[定义多个响应式数据] --> B[将数据源放入数组作为watch的监听源]
B --> C[任意数据源发生变化]
C --> D[触发回调函数]
D --> E[解构新值和旧值数组,处理业务逻辑]

Getter函数监听

当我们需要监听的目标不是直接的响应式数据,而是基于响应式数据计算出的值时,可以使用getter函数作为watch的监听源。这种方式让我们能够灵活定义监听的计算逻辑。

基本用法

Getter函数需要返回我们想要监听的计算结果,当这个结果发生变化时,watch就会触发回调函数。

import { reactive, watch } from 'vue'

// 定义响应式状态对象
const cart = reactive({
  items: [
    { id: 1, name: 'Vue 3 实战教程', price: 59, quantity: 1 },
    { id: 2, name: 'Vuex 从入门到精通', price: 39, quantity: 2 }
  ]
})

// 监听购物车的总金额
watch(
  // Getter函数:计算总金额
  () => cart.items.reduce((total, item) => total + item.price * item.quantity, 0),
  (newTotal, oldTotal) => {
    console.log(`购物车总金额从 ${oldTotal} 元变为 ${newTotal} 元`)
    
    // 实际场景中可以在这里更新结算按钮状态或显示优惠信息
    if (newTotal >= 100) {
      console.log('满足满减条件,可享受10元优惠')
    }
  }
)

// 修改购物车商品数量,触发watch
cart.items[0].quantity = 2

执行流程

flowchart LR
A[定义响应式对象] --> B[创建getter函数,返回计算后的值]
B --> C[将getter函数作为watch的监听源]
C --> D[计算值发生变化]
D --> E[触发回调函数]
E --> F[处理新的计算结果]

数组监听

在Vue 3中监听数组需要注意一些细节,因为Vue的响应式系统对数组的处理和普通对象有所不同。默认情况下,watch会监听数组的引用变化和数组方法(如pushpopsplice等)的调用,但不会监听数组元素的直接索引修改。

往期文章归档
免费好用的热门在线工具

监听数组整体变化

当使用数组方法修改数组时,watch会自动触发:

import { ref, watch } from 'vue'

const todoList = ref(['学习Vue 3', '编写项目实战'])

// 监听数组整体变化
watch(todoList, (newList, oldList) => {
  console.log('待办事项列表发生变化:', newList)
})

// 使用数组方法修改数组,触发watch
todoList.value.push('优化代码性能')
todoList.value.pop()

监听数组内部元素变化

如果需要监听数组元素的直接修改(如arr[0] = '新值'),需要开启deep选项:

import { ref, watch } from 'vue'

const numbers = ref([1, 2, 3, 4])

// 开启deep选项,监听数组内部元素变化
watch(numbers, (newNumbers, oldNumbers) => {
  console.log('数组元素发生变化:', newNumbers)
}, { deep: true })

// 直接修改数组元素,触发watch
numbers.value[0] = 100

执行流程

flowchart LR
A[定义响应式数组] --> B[使用watch监听数组,可选开启deep]
B --> C[修改数组]
C --> D{修改方式?}
D -->|数组方法| E[触发watch回调]
D -->|索引修改| F{是否开启deep?}
F -->|是| E
F -->|否| G[不触发watch回调]

课后Quiz

问题1

如何在Vue 3中同时监听多个响应式数据的变化?请写出代码示例。

答案解析: 可以将多个数据源放入数组中作为watch的第一个参数,回调函数会接收新值数组和旧值数组:

import { ref, watch } from 'vue'

const name = ref('')
const age = ref(0)

watch(
  [name, age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log(`姓名从 ${oldName} 变为 ${newName}`)
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

问题2

当需要监听响应式对象中多个属性的计算结果时,应该使用什么方式?请写出代码示例。

答案解析: 使用getter函数作为watch的监听源,在getter函数中计算需要监听的结果:

import { reactive, watch } from 'vue'

const product = reactive({
  stock: 100,
  sales: 30
})

// 监听剩余库存
watch(
  () => product.stock - product.sales,
  (newStock, oldStock) => {
    console.log(`剩余库存从 ${oldStock} 变为 ${newStock}`)
  }
)

问题3

为什么直接修改数组的索引元素时,watch默认不会触发?如何解决这个问题?

答案解析: Vue的响应式系统默认不会监听数组的索引修改,因为这在性能上是低效的。解决方法有两种:

  1. 开启deep选项,深度监听数组内部元素变化
  2. 使用Vue提供的数组方法(如pushsplice等)来修改数组

常见报错解决方案

报错1:watch source must be a ref, reactive object, getter function, or array of these

  • 原因watch的监听源类型不正确,不是Vue支持的响应式数据源类型。
  • 解决方法:确保监听源是ref、reactive对象、getter函数或这些类型的数组。例如,如果你想监听普通变量,需要先将其转换为ref:
// 错误用法:监听普通变量
let count = 0
watch(count, () => { /* ... */ })

// 正确用法:转换为ref
const count = ref(0)
watch(count, () => { /* ... */ })

报错2:数组元素修改后watch不触发

  • 原因:直接修改数组索引元素,Vue默认不监听这种变化。
  • 解决方法:开启deep选项,或者使用数组方法修改数组:
// 方法1:开启deep选项
watch(numbers, () => { /* ... */ }, { deep: true })

// 方法2:使用数组方法
numbers.value.splice(0, 1, 100)

报错3:Cannot read property 'value' of undefined

  • 原因:在getter函数或回调函数中访问了未定义的响应式属性。
  • 解决方法:确保所有访问的属性都已正确定义,或者添加可选链操作符:
// 错误用法:访问未定义的属性
watch(() => user.address.city, () => { /* ... */ })

// 正确用法:添加可选链
watch(() => user?.address?.city, () => { /* ... */ })

参考链接

参考链接:vuejs.org/guide/essen…

21K代码的全新前端框架!React的掘墓人现世!

作者 foreverflying
2026年1月24日 00:26

废话一会儿再说,先上Demo,一个经典桌游“璀璨宝石”:splendor.ezh.dev ,可以2~4人联机玩,进了 游戏可以看具体规则,说明里还藏着源码库地址。

Screenshot 2026-01-24 at 4.02.37 AM.pngScreenshot 2026-01-24 at 4.07.46 AM.pngScreenshot 2026-01-24 at 9.31.58 PM.png

性能碾压React,而易用性可说是达到前端框架的极限了。你要小心的是,你用了这个以后,再回去用React写代码会犯恶心。

核心代码只有2500行,min.js合计21KB,接口共11个API,学习曲线不能说平缓,只能说根本没有。

上面这个界面交互非常复杂的Demo,游戏页面的实现代码只有八百行左右。当然,十个码农有九个懒得去看八百行代码,下面是个微型Demo,四十多行代码,是个很直观的例子,一眼懂,完全不必读文档。

Screenshot 2026-01-24 at 4.24.24 AM.png

import { $ezh, bindData, Com, useState } from 'ezh'

type Player = {
    name: string
    isSelected: boolean
}

const PlayerView: Com<{ player: Player, onRemove: (player: Player) => void }> = (
    { player, onRemove },
) => {
    return <p>
        <input type='checkbox' checked={bindData(player, 'isSelected')} />
        Name: {player.name}
        <input type='button' value='Remove' onclick={() => onRemove(player)} />
    </p>
}

const MainView: Com = () => {
    const state = useState({
        name: '',
        players: [] as Player[],
    })
    const { players } = state
    const onAdd = () => {
        if (state.name) {
            const isSelected = (players.length + 1) % 2 === 0
            players.push({ name: state.name, isSelected })
            state.name = ''
        }
    }
    const onRemove = (player: Player) => {
        const idx = players.indexOf(player)
        players.splice(idx, 1)
    }
    const selectedCount = players.reduce((count, player) => count + (player.isSelected ? 1 : 0), 0)
    return <div>
        <p>Player count: {players.length}, seleted count: {selectedCount}</p>
        <p>
            {players.map((player, i) => <PlayerView key={i} player={player} onRemove={onRemove} />)}
        </p>
        <p>
            <input type='text' value={bindData(state, 'name')} />
            <input type='button' value='Add' onclick={onAdd} />
        </p>
    </div>
}

$ezh.render(document.getElementById('root')!, MainView)

自我介绍下,我叫 Ezh,读音被定为同"edge"。先秀一下2500行的小肌肉:

  1. TypeScript Only。之前某个知名框架说TS太麻烦,他们于是回归了纯JS,所以Ezh当然也要极端一点:只支持TS写代码和编译代码,绝不支持JS。每个html元素、每个组件在Ezh里都有静态类型检查,拼错了属性名当场甩脸给你看。
  2. TSX语法当然是标配,让你扔掉React扔得心安理得,完全无痛。甚至过去的React屎山,代码垃圾但是TSX部分还能接着用,也许你删一删它的垃圾代码,剩下的部分可以轻松移植到Ezh框架上来。
  3. 基于全新原创的机制的DOM刷新,以O1复杂度,实现最小变化单位(单个字段)触发最小刷新集合(逻辑上不该刷新的绝对不会刷新)。虚拟DOM树Diff,这种听起啦高大上的垃圾路线可以滚了。
  4. 说到垃圾(React我没说你哈),这个框架可以配置延迟释放和垃圾收集:想象你刚费了九牛二虎之力组装出了一个结构复杂的1000+元素的DOM分支,没两秒用户点了另一个标签把它销毁了,三秒之后他又点回来了。现在这两个用户动作带来的开销接近于0了。
  5. 中国人喜欢大一统不是,一个包全功能最理想。React主包尺寸172K,但Route得加,Redux得要,用TS还得Type包单算。Ezh的npm包解包尺寸41.5K,其中18K的Type文件。无限自由的状态管理、强大却易用的路由表、直观的组件生命期管理,都浓缩在这剩下的21K代码里了。

目前 npm 包 ezh 直接可用,用法参考 Demo源码 ,一小时学习成本搞定。核心源码开源早就在计划中,但可能会晚些。

🚀 Vue3 响应式进阶:多层嵌套数据不更新?别让数据"躺平"了!

作者 子兮曰
2026年1月25日 14:15
前言:小明的表单噩梦 想象一下,你是小明,一个刚转前端的后端工程师。老大让你做个复杂的表单页面,用户信息、地址、支付方式,一层套一层。你信心满满地用 reactive 写了个对象,结果发现修改地址字段
❌
❌