阅读视图

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

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

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

Billboard节点是UnityShaderGraph中一个功能强大的顶点变换工具,专门用于实现面向相机的渲染效果。在实时渲染中,Billboard技术被广泛应用于粒子系统、植被渲染、UI元素和特效制作等领域,能够确保特定物体始终面向摄像机,从而提供最佳的视觉效果。

Billboard技术概述

Billboard技术源于计算机图形学中的精灵渲染概念,其核心思想是通过动态调整物体的朝向,使其始终面对观察者。这种技术在游戏开发中具有重要价值:

  • 在粒子系统中用于渲染烟雾、火焰、魔法效果等动态元素
  • 在开放世界游戏中用于优化树木和植被的渲染性能
  • 在UI系统中确保界面元素始终以正确角度显示
  • 在特效制作中创建各种视觉欺骗效果

UnityShaderGraph中的Billboard节点封装了这一复杂技术,让开发者能够通过可视化方式轻松实现面向相机的渲染效果,无需编写复杂的着色器代码。

节点端口详解

Billboard节点包含多个输入和输出端口,每个端口都有特定的功能和用途。

输入端口

Position OS端口接收物体空间的顶点位置数据。这个端口是Billboard变换的基础,提供了需要进行旋转的原始顶点坐标信息。在实际应用中,这个端口通常直接连接到顶点着色器的位置输出,或者与其他位置变换节点相连。

Normal OS端口处理物体空间的法线向量。法线数据对于光照计算至关重要,Billboard节点会对法线进行相应的旋转,确保光照效果在物体旋转后仍然正确。如果忽略法线变换,可能会导致光照异常或材质表现不正确。

Tangent OS端口管理物体空间的切线向量。切线主要用于法线贴图和某些高级着色效果,Billboard节点会同步旋转切线数据,保持与顶点和法线的一致性。在需要复杂材质表现的场景中,正确的切线变换尤为重要。

输出端口

Position输出端口提供旋转后的物体空间顶点位置。这是Billboard节点的核心输出,包含了经过相机对齐变换后的顶点坐标。这个输出通常直接连接到主节点的顶点位置输入,完成最终的顶点变换。

Normal输出端口返回旋转后的物体空间法线向量。变换后的法线确保了光照计算与物体新朝向的一致性,对于保持材质视觉真实性至关重要。

Tangent输出端口提供旋转后的物体空间切线向量。这个输出确保了法线贴图和其他依赖切线空间的着色效果能够正确工作。

控件参数解析

Billboard Mode是Billboard节点最重要的控制参数,决定了物体的对齐方式和旋转行为。

All Axis模式

All Axis模式实现完全相机对齐,物体的所有坐标轴都会与相机坐标系对齐。在这种模式下,物体会完全面向相机,类似于始终正对观察者的广告牌。

这种模式的特点包括:

  • 物体完全面向相机,保持正面朝向观察者
  • 所有轴向都会根据相机方向进行旋转
  • 适用于需要完全正面展示的效果,如粒子特效、公告板文字等
  • 在VR和AR应用中特别有用,确保UI元素始终面向用户

All Axis模式的一个典型应用场景是粒子系统中的精灵渲染。当相机移动时,每个粒子都会自动调整方向,始终以最佳角度面向观察者,从而保证视觉效果的一致性。

Around Y Axis模式

Around Y Axis模式提供受限的对齐方式,物体仅围绕Y轴旋转,保持Y轴方向不变。这种模式在保持物体部分方向稳定的同时,实现基本的面向相机效果。

这种模式的特点包括:

  • 物体围绕世界空间或物体空间的Y轴旋转
  • X轴和Z轴与相机对齐,但Y轴保持原有方向
  • 适用于树木、路灯等需要保持垂直方向的物体
  • 在开放世界游戏中广泛用于植被渲染优化

Around Y Axis模式在大型场景的性能优化中特别有用。通过将3D树木替换为Billboard四边形,可以大幅减少渲染负载,同时通过限制Y轴旋转保持视觉上的自然感。

技术实现原理

理解Billboard节点的内部工作原理有助于更好地使用和调试相关效果。

顶点变换矩阵

Billboard节点的核心是基于视图矩阵的逆向变换。本质上,它计算相机的旋转矩阵,然后将这个旋转应用于输入的顶点数据。在All Axis模式下,节点会提取相机的完整旋转矩阵;而在Around Y Axis模式下,则会提取并修改旋转矩阵,将Y轴分量重置为单位矩阵的Y轴。

数学上,这个过程可以表示为:

旋转矩阵 = 提取相机旋转矩阵
如果模式为Around Y Axis:
    旋转矩阵[1] = [0, 1, 0] // 重置Y轴
变换后位置 = 旋转矩阵 × 原始位置

法线和切线变换

法线和切线的变换遵循与位置数据相同的旋转逻辑,但由于它们是方向向量而非位置点,变换时不考虑平移分量。正确的法线和切线变换确保了光照和材质效果在Billboard变换后仍然保持视觉一致性。

法线变换需要特别注意,由于法线是协变向量,其变换矩阵通常为顶点变换矩阵的逆转置矩阵。但在Billboard这种纯旋转的情况下,由于旋转矩阵是正交矩阵,逆转置矩阵等于原矩阵,因此可以直接使用相同的旋转矩阵。

实际应用案例

Billboard节点在游戏开发中有多种实际应用,以下是一些典型场景。

粒子系统效果

在粒子系统中,Billboard技术是创建各种视觉特效的基础。

火焰和烟雾效果可以通过Billboard四边形配合透明度渐变纹理实现。每个粒子都是一个面向相机的四边形,使用噪声纹理和颜色渐变创建动态的火焰和烟雾外观。通过All Axis模式确保无论相机如何移动,效果都能正确显示。

魔法和能量场效果利用Billboard节点创建环绕角色的魔法光环或能量屏障。结合扭曲效果和发光着色器,可以制作出视觉上吸引人的魔法特效。Billboard确保这些效果始终面向玩家,提供最佳的视觉体验。

环境装饰优化

在大型开放世界游戏中,Billboard技术是性能优化的重要手段。

树木和植被渲染使用Around Y Axis模式的Billboard技术,将复杂的3D树木模型替换为简单的四边形,大幅减少三角形数量。当玩家距离较远时,使用Billboard树木;当玩家靠近时,逐渐淡入完整的3D模型。这种LOD(层次细节)策略在保持视觉质量的同时显著提升性能。

远处山脉和云层可以通过Billboard技术创建。使用多层Billboard平面配合透明度混合,可以模拟出具有深度感的远景效果。这种方法比使用完整3D模型更加高效,特别适合移动平台或性能受限的场景。

UI和交互元素

在用户界面和交互设计中,Billboard技术确保重要信息始终可见。

世界空间UI元素使用Billboard技术创建始终面向玩家的对话框、任务提示或交互图标。这在3D游戏中特别有用,玩家可以从任何角度都能清晰看到UI内容。

AR和VR应用中的界面元素通过Billboard技术确保虚拟界面始终面向用户,提供自然的交互体验。无论是信息面板、控制菜单还是虚拟标签,Billboard都能保证最佳的可读性和可用性。

性能优化考虑

使用Billboard节点时需要考虑性能影响,特别是在大量使用的情况下。

渲染性能

Billboard技术通过减少几何复杂度来提升性能,但顶点着色器的计算负载会增加。在移动设备或低端硬件上,需要平衡视觉质量和性能消耗。

优化策略包括:

  • 控制Billboard物体的数量,避免在同一帧中渲染过多Billboard
  • 使用LOD系统,根据距离动态切换Billboard和完整模型
  • 合并多个Billboard物体,减少绘制调用
  • 在性能敏感的区域使用更简单的Billboard效果

内存和带宽

Billboard通常使用简单的四边形几何体,这有助于减少内存占用和顶点数据传输带宽。但在使用高质量纹理时,需要注意纹理内存的消耗。

优化建议:

  • 使用纹理图集将多个Billboard纹理合并为一张大图
  • 根据距离使用不同分辨率的纹理
  • 压缩纹理格式以减少内存占用
  • 合理管理纹理的加载和卸载,避免内存峰值

常见问题与解决方案

在使用Billboard节点时可能会遇到一些常见问题,以下是相应的解决方案。

光照异常

问题描述:Billboard物体上的光照显示不正确,高光或阴影位置异常。

解决方案:

  • 确保正确连接Normal OS端口,并提供准确的法线数据
  • 检查Billboard模式是否适合场景需求
  • 在复杂光照环境下,考虑使用自定义光照模型或简化光照计算
  • 验证法线贴图是否正确应用,确保切线数据正确变换

深度排序问题

问题描述:Billboard物体与其他物体的深度排序错误,出现穿透或遮挡异常。

解决方案:

  • 调整渲染队列顺序,确保Billboard物体在正确的渲染阶段绘制
  • 使用Alpha混合时,注意透明物体的渲染顺序问题
  • 在粒子系统中使用软粒子技术缓解深度冲突
  • 考虑使用自定义深度偏移解决特定的排序问题

运动模糊和抗锯齿

问题描述:快速移动的Billboard物体可能出现运动模糊异常或抗锯齿效果不佳。

解决方案:

  • 在运动剧烈的Billboard物体上禁用运动模糊,或使用自定义运动向量
  • 调整抗锯齿设置,确保Billboard边缘平滑
  • 对于特别敏感的视觉效果,考虑使用更高分辨率的纹理
  • 在后期处理中应用特定的抗锯齿技术,如TAA(时间性抗锯齿)

高级应用技巧

掌握了Billboard节点的基本用法后,可以探索一些高级应用技巧。

自定义Billboard效果

通过组合Billboard节点与其他ShaderGraph节点,可以创建独特的视觉效果。

倾斜Billboard效果通过修改旋转矩阵,使Billboard物体以特定角度倾斜,而不是完全面向相机。这种效果可以用于创建更有动态感的粒子特效或风格化的视觉元素。

动态朝向Billboard根据游戏逻辑或玩家输入动态调整Billboard的朝向,而不是始终面向主相机。这种技术可以用于创建始终面向特定目标的效果,如追踪导弹的尾焰或指向任务目标的导航标记。

与其他系统的集成

Billboard节点可以与Unity的其他系统集成,创建更复杂的效果。

与VFX Graph集成,在视觉特效图中使用Billboard技术创建高性能的粒子效果。VFX Graph提供了更强大的粒子系统功能,结合Billboard可以实现电影级的视觉效果。

与Shader Graph高级特性结合,如曲面细分、几何着色器或光线追踪,创建更复杂的Billboard效果。这些高级技术可以增强Billboard的视觉质量,提供更逼真或更风格化的外观。


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

JS和PHP操作cookie对比

一、核心操作对比(基础用法)

先通过代码示例直观展示两者操作 Cookie 的核心方式:

1. JavaScript 操作 Cookie

(基于之前封装的工具函数,原生 JS 无内置 API,需手动拼接字符串)

运行

// 设置 Cookie:7天有效期,全站生效
setCookie('user_id', '1001', {
  expires: 60 * 60 * 24 * 7, // max-age 单位秒
  path: '/',
  secure: true // 仅HTTPS
});

// 获取 Cookie
const userId = getCookie('user_id'); // 输出:1001

// 删除 Cookie
removeCookie('user_id', { path: '/' });

2. PHP 操作 Cookie

(PHP 提供内置函数 setcookie()/$_COOKIE,更简洁)

运行

<?php
// 1. 设置 Cookie:7天有效期,全站生效
// 参数:name, value, expire(时间戳), path, domain, secure, httponly
setcookie(
  'user_id',
  '1001',
  time() + 7 * 24 * 60 * 60, // 过期时间戳(当前时间+7天)
  '/', // 全站生效
  '',  // 域名(默认当前域名)
  true, // 仅HTTPS传输
  true  // 禁止JS访问(防XSS)
);

// 2. 获取 Cookie(超全局变量 $_COOKIE)
$userId = $_COOKIE['user_id'] ?? null; // 输出:1001

// 3. 删除 Cookie(设置过期时间为过去)
setcookie('user_id', '', time() - 3600, '/');
?>

二、关键差异对比表

维度 JavaScript 操作 Cookie PHP 操作 Cookie
操作时机 运行在客户端浏览器,随时可读写(页面加载后) 运行在服务端,仅在HTTP 响应头中设置,读取依赖请求头
API 特性 无内置封装,需手动拼接 Cookie 字符串、处理编码 提供 setcookie() 内置函数,参数化配置更规范
HttpOnly 控制 无法设置!HttpOnly 仅能由服务端(PHP)设置 可直接通过 setcookie() 第 7 个参数设置
生效时机 设置后立即生效(可马上读取) 设置的 Cookie 需下一次请求才生效(当前脚本无法读取刚设置的 Cookie)
跨域 / 路径限制 仅能操作当前域名、当前路径下的 Cookie 可设置跨子域名(如 .example.com)、任意生效路径
编码处理 需手动用 encodeURIComponent/decodeURIComponent 建议手动编码(如 urlencode()),PHP 不会自动处理
错误处理 无内置错误提示,需自行判断 可通过 headers_sent() 检查是否已发送响应头(发送后无法设置 Cookie)

三、核心差异详解

1. 生效时机(最易踩坑点)

  • JS:设置 document.cookie = "name=value" 后,立刻能通过 document.cookie 读取到该值,因为操作的是浏览器本地存储。

  • PHPsetcookie() 是往 HTTP 响应头中添加 Set-Cookie 字段,当前脚本的 $_COOKIE 是从本次请求头中读取的,所以刚设置的 Cookie 要等客户端下次请求时才会被带入请求头,当前脚本无法读取。

    示例(PHP 坑点):

    运行

    <?php
    setcookie('test', '123', 0, '/');
    echo $_COOKIE['test'] ?? '未获取到'; // 输出:未获取到(当前请求无该Cookie)
    // 刷新页面后再次执行,才会输出:123
    ?>
    

2. HttpOnly 权限

HttpOnly 是 Cookie 的核心安全属性,作用是禁止 JS 访问该 Cookie,防止 XSS 攻击:

  • JS 完全无法设置 / 修改这个属性,只能由服务端(PHP)控制;
  • PHP 可通过 setcookie() 最后一个参数直接设置,这也是生产环境中登录 token 等关键 Cookie 必须设置的属性。

3. 操作环境限制

  • JS 只能在浏览器环境运行,无法操作非当前域名的 Cookie(浏览器同源策略限制);
  • PHP 运行在服务端,可根据业务需求设置任意生效路径 / 域名(如让 Cookie 在 a.example.comb.example.com 共享)。

四、最佳实践与使用场景

场景 推荐使用 原因
登录 token / 敏感数据 PHP 可设置 HttpOnly 防 XSS,服务端控制更安全
页面临时偏好(如主题) JavaScript 无需请求服务端,本地操作更高效
跨子域名共享数据 PHP 可设置 domain: '.example.com',JS 无法跨子域名操作
立即生效的本地存储 JavaScript 设置后立即读取,PHP 需等待下一次请求

总结

  1. 核心差异:JS 操作客户端本地 Cookie,即时生效但无 HttpOnly 权限;PHP 操作响应头 Cookie,需下次请求生效但可控制全部安全属性;
  2. 安全优先:敏感 Cookie(如 token)必须用 PHP 设置并开启 HttpOnly,避免 JS 窃取;
  3. 效率优先:页面级临时存储(如主题、语言)用 JS 操作,减少服务端请求。

系统日志分析:排查 Linux 系统异常重启原因

在Linux服务器运维工作中,系统突发关机或异常重启是高频高发的故障场景,不仅可能导致业务中断、数据丢失,还可能隐藏着安全风险。导致这类问题的原因复杂多样,常见诱因包括以下几类:

•供电故障
•软件/硬件错误
•内存故障
•未授权用户操作
系统重启与关机均属于核心系统事件,直接关联业务稳定性与数据安全,因此管理员必须将这类事件纳入日常监控重点。而系统日志(Syslog)作为Linux系统的“运行日记”,正是获取重启、关机事件详细信息的核心载体,定期监控分析Syslog是排查此类故障的关键前提。

从操作场景来看,局域网内的Linux用户可直接通过命令执行关机操作,其中Linux系统关机命令的基础语法为:shutdown [OPTIONS] [TIME] [MESSAGE]。掌握这一基础命令,能帮助管理员快速区分操作是人为执行还是系统异常触发。

若故障排查过程中怀疑是人为操作导致的系统关机,管理员可通过检查认证日志文件精准定位操作记录,包括操作人、操作时间等关键信息。需要注意的是,个别用户还可能通过远程登录的方式执行关机指令,这类操作往往更具隐蔽性,需重点排查。

重启事件的日志记录形式

Dec 24 21:03:41 ip-172-31-34-37 sudo: joker : TTY=pts/0 ; PWD=/home/joker ; USER=root ; COMMAND=/sbin/shutdown -r now

除了人为操作,系统硬件故障、软件崩溃等也会导致重启,这类信息可通过检索内核日志定位。但实际运维场景中,系统日志数据量大、类型繁杂,人工逐条筛选不仅耗时费力,还容易遗漏关键信息。

因此,借助专业的日志管理解决方案成为高效运维的必然选择——这类工具可自动完成日志数据的采集、解析,将杂乱的原始日志转换为直观的价值信息,并生成开箱即用的统计报表,大幅提升故障排查效率。

高效挖掘Linux重启事件价值:日志管理工具的核心作用

在众多日志管理工具中,EventLog Analyzer凭借全面的功能成为运维人员的常用选择。

作为运维人员常用的日志管理工具,EventLog Analyzer具备全网络日志整合监控能力。可跨平台覆盖Linux等各类系统,实现日志数据的集中采集、梳理与实时监控,打破分散日志管理壁垒,让管理员全面掌握全网日志动态,为运维决策提供完整数据支撑,提升运维效率。

image.png

针对系统关机、重启等关键事件,EventLog Analyzer可精准触发实时告警,支持短信、邮件等多渠道通知,助力管理员即时响应异常,避免故障扩大,保障系统稳定运行。EventLog Analyzer具备智能报表生成功能,自动汇总日志数据生成详尽报表,清晰呈现关键信息,助力管理员快速追溯Linux故障根源、定位责任,缩短排查周期。

依托全面日志管理分析能力,EventLog Analyzer实时监控异常日志、识别风险,提供合规报表,构建全方位Linux运维安全体系,规避安全风险,保障业务系统稳定。

nuxt配置之head动态配置

讲一下nuxt中的配置,那么首先的配置文件是nuxt.config.js,那么也就是说在我们的项目的根目录的一个nuxt.config.js文件。然后大家需要明确的一点就是这个文件的所有的配置,是全局配置。

image.png

head来说,那我们知道nuxt它是可以解决seo的部分问题的。比如说它可以解决每个网页都有他自己所独有的title描述以及关键字。那如果说我这里有很多页面,到那时呢我都没有进行配置,那这个时候就会走全局的配置。

当然,如果某个页面配置了,就会走独有的页面的head。

pages里边新建一个页面。比如list.vue

<template>
  <div>列表页</div>
</template>

然后一个about页面

<template>
  <div>关于页</div>
</template>

所有页面的title全是页面的项目名。因为这些页面都没有独有的head配置。所以如果想要做针对单独页面的配置。那应该怎么去配置呢?

可以注意一下写法,参考文档这个api有一个head的单篇介绍。只不过文档写得不是特别的完整。

在全局是head的一个对象,然后在单独页面就是一个head的一个函数。

image.png

image.png

动态title

做动态title标题。包括描述或者关键词可能都是动态的。

然后呢现在我们做的是点击每一个进入到这个文章的详情页。

/pages/news/index.vue

<template>
  <div>
    <h1>新闻列表</h1>
    <ul>
      <li 
        v-for='item in newList' 
        :key='item.id'
        @click='goDetail(item.id)'
      >
        {{item.title}}
      </li>
    </ul>
  </div>
</template>

<script type="text/javascript">
export default {
  data() {
    return {
      newList: [
        { id: 1, title: '111' },
        { id: 2, title: '222' },
        { id: 3, title: '333' },
        { id: 4, title: '444' },
        { id: 5, title: '555' },
      ]
    }
  },
  methods: {
    goDetail(id) {
      this.$router.push({
        path: `/news/${id}`
      })
    }
  }
}
</script>
// _id.vue
<template>
  <div>
    {{ id }} 新闻的详情页
  </div>
</template>

<script type="text/javascript">
export default{
  head() {
    return {
      title: this.id,
      meta: [
        { hid: 'description', name: 'description', content: '此处是网站描述' },
        { hid: '', name: 'keywords', content: '此处是网站关键词' }
      ]
    }
  },
  data () {
    return {
      id: 0
    }
  },
  created() {
    this.id = this.$route.params.id
  }
}
</script>

总结

head 可以全局配置,也可以局部配置,也可以动态配置。

Vue 3自定义指令如何赋能表单自动聚焦与防抖输入的高效实现?

自定义指令在表单中的扩展

自动聚焦指令(v-focus)

在表单交互中,自动聚焦是一个常见的需求,尤其是在用户打开页面或弹窗时,希望输入框自动获得焦点。Vue 3允许我们通过自定义指令轻松实现这个功能。

<script setup>
// 定义v-focus指令
const vFocus = {
  mounted: (el) => {
    // 当元素挂载到DOM时自动聚焦
    el.focus()
  }
}
</script>

<template>
  <div>
    <h2>用户登录</h2>
    <input v-focus type="text" placeholder="请输入用户名" />
    <input type="password" placeholder="请输入密码" />
  </div>
</template>

这个指令比HTML原生的autofocus属性更强大,因为它不仅在页面加载时生效,还能在元素动态插入到DOM时自动聚焦,比如在弹窗组件中。

防抖输入指令(v-debounce)

在处理搜索输入等场景时,我们不希望用户每输入一个字符就立即发起请求,这会导致频繁的API调用和性能问题。防抖指令可以帮助我们延迟处理输入事件,直到用户停止输入一段时间后再执行。

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

// 定义防抖指令
const vDebounce = {
  mounted: (el, binding) => {
    let timeoutId
    const delay = binding.value || 500 // 默认500ms延迟
    
    // 监听输入事件
    el.addEventListener('input', (e) => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        // 触发自定义事件,传递输入值
        el.dispatchEvent(new CustomEvent('debounce-input', { 
          detail: e.target.value 
        }))
      }, delay)
    })
  }
}

const searchQuery = ref('')

const handleSearch = (e) => {
  searchQuery.value = e.detail
  console.log('发起搜索:', searchQuery.value)
  // 这里可以添加实际的API调用逻辑
}
</script>

<template>
  <div>
    <input 
      v-debounce="300" 
      type="text" 
      placeholder="请输入搜索关键词"
      @debounce-input="handleSearch"
    />
    <p>搜索关键词: {{ searchQuery }}</p>
  </div>
</template>

这个防抖指令接收一个可选的延迟参数,默认500毫秒。当用户输入时,指令会在用户停止输入指定时间后触发自定义的debounce-input事件,我们可以在组件中监听这个事件来处理实际的搜索逻辑。

表单提交的事件处理与性能优化

避免过度渲染的策略

在处理表单提交时,我们需要注意避免不必要的组件渲染。以下是一些常用的优化策略:

  1. 使用v-once指令:对于不需要更新的静态内容,使用v-once可以让Vue只渲染一次,之后不再重新渲染。
<template>
  <div v-once>
    <h2>用户注册</h2>
    <p>请填写以下信息完成注册</p>
  </div>
  <!-- 表单内容 -->
</template>
  1. 使用计算属性处理复杂逻辑:将复杂的计算逻辑放在计算属性中,而不是模板中,这样可以缓存计算结果,避免重复计算。
<script setup>
import { ref, computed } from 'vue'

const password = ref('')
const confirmPassword = ref('')

// 计算属性:检查密码是否匹配
const isPasswordMatch = computed(() => {
  return password.value && password.value === confirmPassword.value
})
</script>

<template>
  <div>
    <input v-model="password" type="password" placeholder="请输入密码" />
    <input v-model="confirmPassword" type="password" placeholder="请确认密码" />
    <p :style="{ color: isPasswordMatch ? 'green' : 'red' }">
      {{ isPasswordMatch ? '密码匹配' : '密码不匹配' }}
    </p>
  </div>
</template>
  1. 使用watch监听变化:对于需要在数据变化时执行异步操作的场景,使用watch而不是在模板中直接处理。
<script setup>
import { ref, watch } from 'vue'

const email = ref('')
const isEmailAvailable = ref(true)

// 监听邮箱变化,检查邮箱是否已注册
watch(email, async (newEmail) => {
  if (newEmail) {
    // 模拟API调用
    const response = await fetch(`/api/check-email?email=${newEmail}`)
    isEmailAvailable.value = await response.json()
  }
})
</script>

<template>
  <div>
    <input v-model="email" type="email" placeholder="请输入邮箱" />
    <p v-if="email" :style="{ color: isEmailAvailable ? 'green' : 'red' }">
      {{ isEmailAvailable ? '邮箱可用' : '邮箱已被注册' }}
    </p>
  </div>
</template>

表单提交的优化处理

在处理表单提交时,我们需要防止用户重复提交,同时优化提交过程中的性能。

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

const formData = ref({
  username: '',
  password: ''
})
const isSubmitting = ref(false)

const handleSubmit = async () => {
  if (isSubmitting.value) return // 防止重复提交
  
  isSubmitting.value = true
  
  try {
    // 模拟API提交
    await new Promise(resolve => setTimeout(resolve, 1500))
    console.log('表单提交成功:', formData.value)
    alert('注册成功!')
  } catch (error) {
    console.error('表单提交失败:', error)
    alert('注册失败,请稍后重试')
  } finally {
    isSubmitting.value = false
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <input 
      v-model="formData.username" 
      type="text" 
      placeholder="请输入用户名"
      required
    />
    <input 
      v-model="formData.password" 
      type="password" 
      placeholder="请输入密码"
      required
    />
    <button type="submit" :disabled="isSubmitting">
      {{ isSubmitting ? '提交中...' : '注册' }}
    </button>
  </form>
</template>

这个示例中,我们使用isSubmitting状态来防止用户重复提交,同时在提交过程中禁用按钮并显示加载状态,提升用户体验。

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

动态表单渲染

根据条件显示/隐藏字段

在实际应用中,我们经常需要根据用户的选择动态显示或隐藏某些表单字段。Vue 3的条件渲染指令可以轻松实现这个功能。

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

const userType = ref('personal') // personal: 个人用户, company: 企业用户
const formData = ref({
  username: '',
  password: '',
  companyName: '',
  companyAddress: ''
})
</script>

<template>
  <form>
    <select v-model="userType">
      <option value="personal">个人用户</option>
      <option value="company">企业用户</option>
    </select>
    
    <input 
      v-model="formData.username" 
      type="text" 
      placeholder="请输入用户名"
      required
    />
    <input 
      v-model="formData.password" 
      type="password" 
      placeholder="请输入密码"
      required
    />
    
    <!-- 企业用户专属字段 -->
    <div v-if="userType === 'company'">
      <input 
        v-model="formData.companyName" 
        type="text" 
        placeholder="请输入企业名称"
        required
      />
      <input 
        v-model="formData.companyAddress" 
        type="text" 
        placeholder="请输入企业地址"
        required
      />
    </div>
    
    <button type="submit">注册</button>
  </form>
</template>

动态生成表单字段

在更复杂的场景中,我们可能需要根据后端返回的配置动态生成整个表单。这时可以结合v-for和动态组件来实现。

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

// 模拟后端返回的表单配置
const formConfig = ref([
  {
    type: 'text',
    label: '用户名',
    name: 'username',
    placeholder: '请输入用户名',
    required: true
  },
  {
    type: 'password',
    label: '密码',
    name: 'password',
    placeholder: '请输入密码',
    required: true
  },
  {
    type: 'email',
    label: '邮箱',
    name: 'email',
    placeholder: '请输入邮箱',
    required: false
  },
  {
    type: 'select',
    label: '用户类型',
    name: 'userType',
    options: [
      { value: 'personal', label: '个人用户' },
      { value: 'company', label: '企业用户' }
    ],
    required: true
  }
])

const formData = ref({})

// 计算属性:检查表单是否完整
const isFormValid = computed(() => {
  return formConfig.value.every(field => {
    if (!field.required) return true
    return formData.value[field.name] && formData.value[field.name].trim() !== ''
  })
})

const handleSubmit = () => {
  if (isFormValid.value) {
    console.log('表单提交:', formData.value)
    alert('表单提交成功!')
  } else {
    alert('请填写所有必填字段!')
  }
}
</script>

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in formConfig" :key="field.name" class="form-group">
      <label>{{ field.label }} {{ field.required ? '*' : '' }}</label>
      
      <input 
        v-if="field.type === 'text' || field.type === 'password' || field.type === 'email'"
        :type="field.type"
        :placeholder="field.placeholder"
        v-model="formData[field.name]"
      />
      
      <select v-else-if="field.type === 'select'" v-model="formData[field.name]">
        <option 
          v-for="option in field.options" 
          :key="option.value"
          :value="option.value"
        >
          {{ option.label }}
        </option>
      </select>
    </div>
    
    <button type="submit" :disabled="!isFormValid">提交</button>
  </form>
</template>

<style scoped>
.form-group {
  margin-bottom: 1rem;
}

label {
  display: block;
  margin-bottom: 0.5rem;
  font-weight: bold;
}

input, select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

button {
  padding: 0.5rem 1rem;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

课后Quiz

问题1:如何在Vue 3中创建一个自定义指令,实现输入框的防抖功能?

答案解析:

<script setup>
const vDebounce = {
  mounted: (el, binding) => {
    let timeoutId
    const delay = binding.value || 500
    
    el.addEventListener('input', (e) => {
      clearTimeout(timeoutId)
      timeoutId = setTimeout(() => {
        el.dispatchEvent(new CustomEvent('debounce', { 
          detail: e.target.value 
        }))
      }, delay)
    })
  }
}

const handleDebounce = (e) => {
  console.log('防抖输入:', e.detail)
}
</script>

<template>
  <input v-debounce="300" @debounce="handleDebounce" placeholder="请输入内容" />
</template>

这个防抖指令通过监听输入事件,使用setTimeout延迟处理,每次输入时清除之前的定时器,确保只有在用户停止输入指定时间后才会触发处理函数。

问题2:在动态表单渲染中,如何根据不同的字段类型渲染不同的输入组件?

答案解析: 可以使用v-ifv-else-ifv-else指令结合v-for来实现:

<template>
  <div v-for="field in fields" :key="field.name">
    <input 
      v-if="field.type === 'text'"
      type="text"
      v-model="formData[field.name]"
    />
    
    <input 
      v-else-if="field.type === 'password'"
      type="password"
      v-model="formData[field.name]"
    />
    
    <select 
      v-else-if="field.type === 'select'"
      v-model="formData[field.name]"
    >
      <option 
        v-for="option in field.options" 
        :key="option.value"
        :value="option.value"
      >
        {{ option.label }}
      </option>
    </select>
    
    <!-- 可以继续扩展其他字段类型 -->
  </div>
</template>

这种方法可以根据字段的type属性动态渲染不同的输入组件,实现灵活的动态表单。

常见报错解决方案

1. 自定义指令无法生效

错误现象: 自定义指令在模板中使用后没有产生预期效果。

可能原因:

  • 指令名称注册错误:在<script setup>中,自定义指令需要以v开头的驼峰命名,如vFocus,在模板中使用v-focus
  • 钩子函数使用错误:比如在created钩子中操作DOM,此时元素还未挂载到DOM树中。
  • 指令作用在组件上:自定义指令默认作用于组件的根元素,如果组件有多个根元素,可能会导致意外行为。

解决方案:

  • 确保指令名称正确注册,在<script setup>中使用v开头的驼峰命名。
  • 在正确的钩子函数中操作DOM,如mountedupdated
  • 避免在组件上使用自定义指令,或者确保组件只有一个根元素。

2. 动态表单渲染性能问题

错误现象: 当表单字段较多时,渲染速度慢,用户输入卡顿。

可能原因:

  • 不必要的响应式更新:表单数据的每个字段都被设置为响应式,导致频繁的更新。
  • 复杂的计算属性:在计算属性中执行复杂的逻辑,导致每次更新都需要大量计算。
  • 没有合理使用v-forkey属性:导致Vue无法正确跟踪元素的变化,进行不必要的DOM操作。

解决方案:

  • 使用markRaw标记不需要响应式的静态数据,如表单配置。
  • 优化计算属性,将复杂逻辑拆分为多个简单的计算属性,或者使用watch处理异步逻辑。
  • 确保v-forkey属性使用唯一且稳定的值,如字段的name属性。

3. 表单提交重复触发

错误现象: 用户点击提交按钮后,表单被多次提交。

可能原因:

  • 没有防止重复提交的机制:用户快速点击按钮导致多次触发提交事件。
  • 异步操作没有正确处理:在提交过程中,状态没有及时更新,导致用户可以再次点击。

解决方案:

  • 使用一个状态变量(如isSubmitting)来标记提交状态,在提交过程中禁用按钮。
  • finally块中重置提交状态,确保无论成功还是失败都能恢复按钮状态。
const handleSubmit = async () => {
  if (isSubmitting.value) return
  
  isSubmitting.value = true
  
  try {
    // 提交逻辑
  } catch (error) {
    // 错误处理
  } finally {
    isSubmitting.value = false
  }
}

参考链接:

【绘制】Paint 指令与栅格化(Raster):逻辑坐标如何变成真正的位图数据。

在《浏览器底层手记》的前四章中,我们已经完成了页面结构的搭建、样式的计算、几何坐标的确定以及图层的划分。

现在的浏览器手里拿着一堆“透明胶片”(图层),并且知道每一块胶片该叠放在什么位置。但问题是:胶片上还是空的

这一章,我们将见证渲染流水线中最具魔力的时刻:浏览器如何指挥 GPU,将抽象的布局信息转化成屏幕上真实可见的像素点


【绘制】Paint 指令与栅格化:像素的诞生

从逻辑坐标到屏幕像素,并不是一步到位的,它经历了一个从“画法说明书”到“位图数据”的转化过程。

一、 绘制(Paint):生成一份“画法说明书”

很多人误以为“绘制”就是画出像素,但在浏览器内核里,这一步产生的其实是绘制指令(Painting Records)

想象一下,你请一位画家画画。你不会直接告诉他每个像素的颜色,而是给他一张清单:

  1. 先画一个红色的矩形。
  2. 在矩形中心写上白色的文字。
  3. 给矩形加一个阴影。

渲染进程的主线程会遍历布局树(Layout Tree),生成这样一个包含绘图序列的清单。

  • 层叠顺序: 绘制不仅关注位置,更关注“谁在谁上面”。它会严格遵循 CSS 的层叠上下文(Stacking Context)顺序。

二、 栅格化(Raster):将指令转化为像素

有了“说明书”,谁来执行?答案是合成线程(Compositor Thread)

主线程将生成的绘图指令提交(Commit)给合成线程。随后,合成线程启动栅格化线程池(Raster Worker Pool)

  • 什么是栅格化? 简单来说,就是根据绘图指令,计算出每一个像素点的颜色值,最终生成位图(Bitmap)
  • 瓦片(Tiles)先行: 为了节省内存,合成线程会优先栅格化**视口(Viewport)**内的瓦片。

三、 GPU 加速:像素的“超级工厂”

在过去,栅格化是靠 CPU 完成的(软件渲染),速度较慢。现在的浏览器普遍采用 GPU 加速栅格化

  1. 指令传输: 合成线程将绘图指令通过 GL/Vulkan/D3D 等图形 API 发送给 GPU。
  2. Skia 图形引擎: Chrome 使用 Skia(一个高性能 2D 图形库)来指挥 GPU。GPU 极其擅长并行处理数学计算,能在一瞬间完成数百万个像素的着色。
  3. 纹理(Texture): 栅格化后的位图会以“纹理”的形式存储在 GPU 的显存中。

四、 显示(Display):最后的临门一脚

当所有的瓦片纹理都准备好后,合成线程会生成一份“合成帧(Compositor Frame)”。

  1. 提交: 合成帧被发送给浏览器进程(Browser Process)
  2. 显示: 浏览器进程将其通过操作系统的 UI 接口(如 Windows 的 DWM)提交给显卡。
  3. 刷新: 当你的显示器刷新(通常是 60Hz,即每 16.6ms 一次)时,显卡从缓冲区取出这帧数据,打在屏幕上。

💡 给前端开发者的硬核贴士

  • “重绘”(Repaint)的代价: 改变 colorvisibility 会重新生成绘图指令并再次栅格化。虽然它不涉及布局计算(比重排快),但如果图层很大,栅格化的开销依然不可忽视。
  • 为什么 Canvas 比 DOM 快? DOM 元素的渲染受制于整个渲染管线(DOM->Layout->Paint->Raster),而 Canvas 允许开发者直接通过 API 越过前面的步骤,直接向栅格化层甚至 GPU 写入绘图指令,路径更短,控制力更强。
  • 内存监控: 每一个合成层都意味着显存中的一张位图。如果你的独立站图片过多或 will-change 滥用,会导致显存占用过高,在低配手机上甚至会导致浏览器崩溃(OOM)。

结语

至此,我们的“奇幻漂流”已经完成了从 0 到 1 的物理闭环:从服务器端的字节流,最终变成了显示器上的光点。

但在现代 Web 开发中,标准的流水线已经不能满足我们对性能和视觉的极致追求了。我们想要更早、更直接地介入这个过程。

Python 与 Nodejs 哪个更快

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。

前言

又刷到了Python 与 Nodejs 哪个更快的这类话题。巧的是在GitHub还开源了类似的计算机语言性能比较的开源库——speed-comparison。

单纯从性能上比较,speed-comparison已经给出了结论:Python(PyPy)>Javascript(nodejs)>Python(CPython)

image.png

PyPy3和 Python3(CPython)的差异在于解释器实现方式。Python3 是官方默认的 C 语言实现,而 PyPy3 是用 RPython 编写的替代实现,并引入了 JIT(即时编译) 技术。

speed-comparison测评数据属于较客观的,speed-comparison测评数据是进行莱布尼茨公式实现π的计算快慢。

image 1.png

另外考虑公平性,做了以下处理:

  1. 实现必须是单线程的。无多线程、异步或并行处理
  2. 允许使用更宽寄存器的SIMD优化,但必须独立,而非取代标准实现。swift-simdcpp-avx2
  3. 使用语言的惯用代码。编译器优化标志没问题
  4. 所有实现必须使用现有实现中所示的莱布尼茨公式

speed-comparison给出测评的语言不只有Python、Nodejs,常用语言也包括了,如:Java、C、C++等。

好奇的读者,可以浏览这个网页:niklas-heer.github.io/speed-compa…

再来一起看看网友们高赞评论。

高赞评论

【网友1】

如果不是谷歌那个大聪明通过 v8 让人们意识到「原来 js 能跑这么快」,压根就不会有现在 JavaScript 的生态。

【网友2】

Python 其实是斩杀线,比Python还慢的就直接斩杀了。

Node.js 的 V8 JavaScript/WASM 引擎是 JIT 的,它的 非常精妙,连 JVM 和 CLR 这两个老牌的都是要服气的。

【网友3】

nodejs目前的解释器使用是v8 engine,它是一个 JIT。所以可以大幅增加运行时的性能。

python目前的主流解释器是 CPython,它还是一个常规的解释器也就是只能一行行解释,不能在运行时优化部分代码为机器码。

所以目前的情况是 nodejs 大幅快于 python

【网友4】

Python这种常年倒数的就不要来找JS碰瓷了。

我们常吐槽JS慢,是拿它跟C、C++、Rust这些编译型语言比的,但JS的性能可谓是脚本语言的天花板,打python就像暴打小朋友一样。

image 2.png

总结

网友们的评论较主观没有数据说明,大家看看热闹就好。

如果一定要从性能方面比较,不考虑应用场景、社区、难易等等方面。

可以参考speed-comparison,自己也能拉取speed-comparison代码在本机电脑上跑一遍数据。

原生JS实现双栏布局拖拽【附源码】

前言

之前有个老的项目,需要使用 jQuery 实现这种双栏布局拖拽的效果,特此记录一下,分享给大家。

实现思路讲解

这个双栏拖拽布局,本质上只做了一件事:

通过拖拽中间的分割线,动态改变左侧容器的宽度,右侧自动填充剩余空间。

我们可以把整个实现拆成 4 个核心步骤 来理解。

一、整体布局结构是关键

先看最核心的 DOM 结构(简化后):

<div class="content-container">
  <div class="left-container"></div>
  <div class="divider-container"></div>
  <div class="right-container"></div>
</div>

为什么要这样结构?

  • 父容器 .content-container 使用 flex
  • 左侧 .left-container 固定宽度(但可变)
  • 中间 .divider-container拖拽手柄
  • 右侧 .right-container 使用 flex-grow: 1 自动填充剩余空间

👉 重点:我们只需要控制 left 的宽度,right 会自动变化

CSS 里最关键的几行是:

.content-container {
  display: flex;
}

.left-container {
  flex-shrink: 0;
}

.right-container {
  flex-grow: 1;
}

二、拖拽的核心原理(一定要理解)

拖拽其实并不神秘,本质只有 3 个事件:

  1. mousedown:开始拖拽
  2. mousemove:拖拽过程中,持续计算距离
  3. mouseup:结束拖拽

拖拽时我们关心什么?

只关心 鼠标在 X 轴上移动了多少

当前宽度 = 初始宽度 + (当前鼠标 X - 按下时鼠标 X)

代码里对应的是这一段:

var startX = e.clientX;       // 鼠标按下时的 X
var leftWidth = $left.width(); // 左侧初始宽度

setLeftContainerWidth(leftWidth + e.clientX - startX);

三、为什么 mousemove 要绑定在 document 上?

这是一个非常重要的细节,新手最容易踩坑。

$document.on("mousemove", onMousemove);
$document.on("mouseup", onMouseup);

如果绑定在 divider 上会怎样?

  • 鼠标一旦移动太快
  • 光标离开 divider 区域
  • 拖拽立刻失效

👉 所以正确做法是绑定到 document,确保鼠标在页面任何地方都能继续拖拽。

四、防止宽度“拖炸”的边界控制

拖拽时一定要做 最大 / 最小宽度限制,否则:

  • 左边会被拖成负数
  • 或者直接盖住右侧

这里用了一个非常关键的方法:

var setLeftContainerWidth = function (width) {
  var contentWidth = $content.width();
  var dividerWidth = $divider.width();
  var maxLeftWidth = contentWidth - dividerWidth;

  width = Math.min(width, maxLeftWidth);
  $left.width(width);
};

这里做了什么?

  • 最大宽度 = 父容器宽度 - 分割线宽度
  • 使用 Math.min 防止超过最大值

👉 这一步是拖拽体验是否“丝滑”的关键点之一

五、为什么要加 body.dragging 这个状态?

当拖拽开始时:

$body.addClass("dragging");

结束时:

$body.removeClass("dragging");

对应 CSS:

body.dragging {
  cursor: col-resize;
}

body.dragging .left-container,
body.dragging .right-container {
  pointer-events: none;
}

这一步解决了什么问题?

  1. 统一鼠标样式
  2. 防止 iframe / 内部元素抢占鼠标事件
  3. 避免拖拽中断(尤其是老项目、复杂页面)

👉 这是很多“看起来能拖,但偶尔会断”的根本解决方案

六、双击 / ESC / 按钮恢复布局的设计思路

为了让体验更好,这里加了几个「快捷恢复」方式:

1️⃣ 点击中间手柄,恢复 50%

recoverWidth();
function recoverWidth() {
  $left.width($content.width() / 2);
}

2️⃣ 按 ESC 键恢复

$document.on("keydown", function (e) {
  if (e.key === "Escape") {
    recoverWidth();
  }
});

3️⃣ 左右双箭头一键全屏

setLeftContainerWidth(0);           // 左边隐藏
setLeftContainerWidth($content.width()); // 左边全屏

并且配合 CSS 动画:

.left-container.animating {
  transition: width 0.4s ease;
}

👉 这让“程序员工具类页面”的体验直接上一个档次

七、实现这个功能的几个核心关键点(必看)

如果你只记住下面 5 条,也能自己写出来 👇

  1. 布局用 flex,只改左侧宽度
  2. mousemove / mouseup 一定绑定 document
  3. 拖拽时要保存起始 X 和初始宽度
  4. 必须做最大 / 最小宽度限制
  5. 拖拽中禁用 pointer-events,防止事件丢失

最后

这个方案虽然用了 jQuery,但实现思路是完全通用的

  • 原生 JS
  • Vue / React
  • 甚至桌面端 WebView

只要你理解了「拖拽本质是计算位移」,这个效果你以后随手就能写出来。

附源码

github.com/zm8/wechat-…

Vue避坑:v-for中ref绑定失效?函数Ref优雅破局

在 Vue 开发中,ref 是最常用的响应式 API 之一,用于绑定 DOM 元素或普通数据。但在 v-for 循环场景中,直接绑定 ref 会出现复用冲突、定位混乱等问题。函数 Ref(Function Ref)作为 Vue 提供的解决方案,能精准处理循环中的 ref 绑定。本文将拆解 v-for 中 ref 的痛点,详解函数 Ref 的原理、用法及最佳实践。

一、v-for 中直接绑定 ref 的痛点

常规场景下,我们通过 ref="xxx" 绑定单个 DOM 元素,再通过 ref.value 访问。但在 v-for 循环中,直接绑定固定名称的 ref 会导致所有循环项共享同一个 ref,无法单独定位某一项元素。

<!-- 错误示例:所有列表项共享同一个 ref -->
<template>
  <ul>
    <li v-for="item in list" :key="item.id" ref="listItem">
      {{ item.name }}
    </li>
  </ul>
</template>

<script setup>
import { ref } from 'vue';
const listItem = ref(null); // 仅能获取最后一个 li 元素
const list = ref([{ id: 1, name: '项1' }, { id: 2, name: '项2' }]);
</script>

上述代码中,循环生成的多个 li 元素均绑定到 listItem,最终 ref.value 只会指向最后一个渲染的元素,无法区分和操作单个循环项,这就是直接绑定 ref 的核心痛点。

二、函数 Ref:v-for 场景的专属解决方案

2.1 什么是函数 Ref?

函数 Ref 是将 ref 绑定值设为一个函数,该函数会在元素渲染、更新或卸载时被调用,接收当前元素(或组件实例)作为参数。通过函数逻辑,可实现对循环项 ref 的精准管理。

核心优势:避免 ref 名称冲突,能为每个循环项单独绑定 ref 并存储,支持精准定位单个元素。

2.2 基础用法:存储循环项 Ref

最常用场景是将每个循环项的 ref 存储到数组或对象中,通过索引或唯一标识关联,实现单独访问。

<template>
  <ul>
    <li 
      v-for="(item, index) in list" 
      :key="item.id" 
      :ref="el => (listItems[index] = el)" // 函数 Ref 绑定
    >
      {{ item.name }}
    </li>
  </ul>
  <button @click="focusItem(0)">聚焦第一项</button>
</template>

<script setup>
import { ref } from 'vue';
const list = ref([
  { id: 1, name: '项1' },
  { id: 2, name: '项2' },
  { id: 3, name: '项3' }
]);
// 用数组存储每个循环项的 ref
const listItems = ref([]);

// 操作指定项的 DOM 元素
const focusItem = (index) => {
  listItems.value[index]?.focus(); // 精准定位第一项并聚焦
};
</script>

代码解析:通过箭头函数将当前 el(li 元素)赋值给 listItems 数组对应索引位置,listItems 数组会与循环项一一对应,从而实现对单个元素的操作。

2.3 进阶用法:结合唯一标识存储

若循环项存在唯一标识(如 id),可使用对象存储 ref,以 id 为键,避免索引变化导致的 ref 错位(如列表排序、删除项场景)。

<template>
  <ul>
    <li 
      v-for="item in list" 
      :key="item.id" 
      :ref="el => { if (el) itemRefs[item.id] = el; else delete itemRefs[item.id]; }"
    >
      {{ item.name }}
    </li>
  </ul>
  <button @click="scrollToItem(2)">滚动到 id=2 的项</button>
</template>

<script setup>
import { ref, reactive } from 'vue';
const list = ref([
  { id: 1, name: '项1' },
  { id: 2, name: '项2' },
  { id: 3, name: '项3' }
]);
// 用对象存储,键为 item.id
const itemRefs = reactive({});

// 根据 id 操作元素
const scrollToItem = (id) => {
  itemRefs[id]?.scrollIntoView({ behavior: 'smooth' });
};
</script>

代码解析:函数中判断 el 是否存在(元素渲染时 el 存在,卸载时为 null),存在则存入对象,不存在则删除对应键,避免对象中残留已卸载元素的 ref,同时通过 id 定位,不受列表顺序变化影响。

三、函数 Ref 的执行时机与注意事项

3.1 执行时机

  • 元素渲染时:函数被调用,el 为当前 DOM 元素/组件实例,可执行存储逻辑。
  • 元素更新时:若元素重新渲染(如数据变化),函数会再次调用,el 为更新后的元素。
  • 元素卸载时:函数被调用,el 为 null,需清理存储的 ref,避免内存泄漏。

3.2 核心注意事项

  • 避免使用箭头函数以外的函数声明:若使用普通函数,this 指向可能异常(尤其非 <script setup> 场景),建议优先用箭头函数。
  • 清理卸载元素的 ref:元素卸载时 el 为 null,需及时删除数组/对象中对应的 ref,避免存储无效引用。
  • 配合 v-if 时的处理:若循环项中包含 v-if,元素可能条件性渲染,需确保函数 Ref 能处理 el 为 null 的场景,避免报错。
  • 组件 ref 绑定:若循环的是自定义组件,el 会指向组件实例,可访问组件暴露的属性和方法(需通过 defineExpose 暴露)。

四、常见场景实战案例

4.1 批量操作循环项 DOM

需求:批量设置循环项的样式,或批量获取元素尺寸。

<template>
  <div class="item-list">
    <div 
      v-for="(item, index) in list" 
      :key="item.id" 
      :ref="el => (itemEls[index] = el)"
      class="item"
    >
      {{ item.content }}
    </div>
  </div>
  <button @click="setAllItemsRed">所有项设为红色</button>
</template>

<script setup>
import { ref } from 'vue';
const list = ref([{ id: 1, content: '内容1' }, { id: 2, content: '内容2' }]);
const itemEls = ref([]);

const setAllItemsRed = () => {
  itemEls.value.forEach(el => {
    if (el) el.style.color = 'red';
  });
};
</script>

4.2 组件循环中的 Ref 调用

需求:循环自定义组件,通过 ref 调用组件方法。

<!-- 父组件 -->
<template>
  <custom-item 
    v-for="item in list" 
    :key="item.id" 
    :ref="el => (compRefs[item.id] = el)"
    :data="item"
  />
  <button @click="callCompMethod(1)">调用 id=1 组件的方法</button>
</template>

<script setup>
import { reactive } from 'vue';
import CustomItem from './CustomItem.vue';
const list = ref([{ id: 1, data: '数据1' }, { id: 2, data: '数据2' }]);
const compRefs = reactive({});

const callCompMethod = (id) => {
  compRefs[id]?.handleClick(); // 调用子组件暴露的方法
};
</script>

<!-- 子组件 CustomItem.vue -->
<script setup>
import { defineProps, defineExpose } from 'vue';
const props = defineProps(['data']);
const handleClick = () => {
  console.log('子组件方法执行', props.data);
};
// 暴露方法供父组件调用
defineExpose({ handleClick });
</script>

五、总结

函数 Ref 是 Vue 为解决 v-for 中 ref 绑定问题提供的优雅方案,通过函数逻辑实现循环项 ref 的精准存储与管理,规避了常规绑定的冲突与错位问题。在实际开发中,需根据场景选择数组或对象存储 ref,注意清理无效引用,同时结合执行时机处理边界场景。

掌握函数 Ref 后,能轻松应对循环中的 DOM 操作、组件交互等需求,大幅提升 Vue 项目中循环场景的开发效率与代码健壮性。

学习Three.js--加载外部三维模型(gltf)

学习Three.js--加载外部三维模型(gltf)

前置核心说明

GLTF(GL Transmission Format)是 Three.js 官方推荐的首选3D模型格式,也是行业通用标准(被Blender、3ds Max、C4D等主流建模软件支持),相比OBJ/FBX等格式有三大核心优势:

  1. 体积小:采用二进制压缩,文件体积远小于传统格式;
  2. 功能全:原生支持PBR材质、骨骼动画、顶点动画、纹理集、层级结构;
  3. 渲染优:Three.js对GLTF的渲染优化最完善,性能最高。

核心规则

  1. 版本兼容:Three.js r152+ 版本重构了颜色空间系统,废弃 gammaOutput/gammaFactor,改用 outputColorSpace
  2. 渲染配置是关键:GLTF模型(尤其是PBR材质)依赖正确的颜色空间和色调映射,否则会出现偏色、过暗/过亮;
  3. 模型适配:不同类型模型(植物/金属/普通)需针对性调整材质参数(如透明、双面、曝光)。

一、核心渲染配置(解决偏色/过亮问题)

GLTF模型渲染的「底层基础配置」,必须在创建渲染器后立即设置,否则模型视觉效果异常。

1. 颜色空间配置(r152+ 版本)

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true }); // 抗锯齿(可选,提升画质)
// 核心:设置输出颜色空间为SRGB,匹配GLTF模型的颜色标准,解决偏色
renderer.outputColorSpace = THREE.SRGBColorSpace;

2. 色调映射(解决高反射材质过亮)

针对金属/高反射塑料/玻璃材质的GLTF模型,默认渲染会过亮/过曝,需开启色调映射并调整曝光值:

// 启用ACES电影级色调映射(最接近真实物理的色调映射,推荐)
renderer.toneMapping = THREE.ACESFilmicToneMapping;
// 调整曝光值(核心参数,根据模型类型适配)
renderer.toneMappingExposure = 2.0; // 通用推荐值:1.5 ~ 2.5

3. 旧版本兼容(r152 以下)

若项目必须使用旧版本Three.js,需替换为gamma配置:

// 替代 outputColorSpace,解决偏色
renderer.gammaOutput = true;
renderer.gammaFactor = 2.2; // 固定值,匹配SRGB颜色空间

4. 色调映射可选值(按效果优先级)

取值 效果特点 适用场景
THREE.ACESFilmicToneMapping(推荐) 电影级效果,色彩自然,高光压制优秀 所有PBR模型(金属/塑料/植物)
THREE.ReinhardToneMapping 基础色调映射,效果柔和 普通低反射模型
THREE.CineonToneMapping 对比度高,暗部细节丰富 暗色调场景/模型
THREE.LinearToneMapping(默认) 无色调映射,易过曝 仅调试使用

5. 曝光值适配表(按模型类型)

模型类型 推荐曝光值(toneMappingExposure) 说明
普通模型(低反射,如木头/石头) 1.0 ~ 1.5 无需过度调整
高反射模型(金属/塑料/玻璃) 1.5 ~ 2.5 压制高光,避免过亮
植物/透明模型(草/树叶) 2.0 ~ 2.8 提升亮度,凸显细节

二、GLTFLoader 完整使用指南

1. 导入方式(两种场景全覆盖)

方式1:CDN导入(新手/快速测试,推荐)

直接在HTML中引入,无需安装依赖,适配r174版本:

<!-- 先引入Three.js核心库 -->
<script type="module">
  // 导入Three.js核心
  import * as THREE from 'https://esm.sh/three@0.174.0';
  // 导入轨道控制器
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';
  // 导入GLTF加载器(核心)
  import { GLTFLoader } from 'https://esm.sh/three@0.174.0/examples/jsm/loaders/GLTFLoader.js';
  
  // 后续代码写在这里...
</script>
方式2:NPM导入(工程化项目,Vue/React/Vite)
# 安装Three.js
npm install three --save
// 模块化导入
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

2. GLTFLoader.load() 方法参数详解

load 是加载GLTF模型的核心方法,支持加载进度、成功、失败回调:

// 创建GLTF加载器实例
const loader = new GLTFLoader();

// 语法:loader.load(url, onLoad, onProgress, onError)
loader.load(
  // 参数1:必传,模型文件路径(本地/CDN)
  './models/grass_medium_01_1k.gltf',
  
  // 参数2:必传,加载成功回调(核心)
  (gltf) => {
    // gltf对象核心属性说明
    console.log('模型加载成功', gltf);
    // gltf.scene:模型的根场景(包含所有模型节点,必加)
    // gltf.scenes:模型包含的所有场景数组
    // gltf.nodes:模型的所有节点(网格/相机/光源)
    // gltf.materials:模型的所有材质
    // gltf.animations:模型的动画数据(如有)
    
    // 1. 将模型添加到场景(核心步骤)
    scene.add(gltf.scene);
    
    // 2. 模型材质适配(按需处理,如植物/透明模型)
    configureMaterialForModel(gltf.scene);
    
    // 3. 自动聚焦模型(让相机对准模型中心,避免看不到)
    autoFocusCamera(gltf.scene, camera, controls);
  },
  
  // 参数3:可选,加载进度回调
  (xhr) => {
    const progress = (xhr.loaded / xhr.total) * 100;
    console.log(`加载进度:${progress.toFixed(1)}%`);
  },
  
  // 参数4:可选,加载失败回调
  (error) => {
    console.error('模型加载失败:', error);
  }
);

3. 核心辅助函数(模型适配+自动聚焦)

3.1 模型材质适配函数(按类型处理)

针对不同模型类型调整材质参数,解决透明、双面、裁剪问题:

/**
 * 配置GLTF模型材质
 * @param {THREE.Object3D} root - 模型根节点(gltf.scene)
 * @param {String} type - 模型类型:plant(植物)/metal(金属)/normal(普通)
 */
function configureMaterialForModel(root, type = 'normal') {
  // 遍历模型所有子节点
  root.traverse((child) => {
    // 只处理网格模型(Mesh)
    if (!child.isMesh) return;
    
    const material = child.material;
    // 处理多材质情况(数组)
    const materials = Array.isArray(material) ? material : [material];
    
    materials.forEach(mat => {
      // 通用配置:开启双面渲染(避免模型背面不可见)
      mat.side = THREE.DoubleSide;
      
      // 按模型类型适配
      switch (type) {
        case 'plant': // 植物/草/树叶模型
          // Alpha裁剪:丢弃透明像素(解决草模型透明区域显示异常)
          mat.alphaTest = 0.5; // 阈值:0.3~0.9,值越大裁剪越严格
          // 可选:若alphaTest无效,启用透明(慎用,可能有排序问题)
          // mat.transparent = true;
          // mat.depthWrite = false; // 关闭深度写入,解决透明闪烁
          break;
          
        case 'metal': // 金属/高反射模型
          // 调整材质粗糙度(可选,让金属更真实)
          if (mat.roughness !== undefined) {
            mat.roughness = 0.1; // 降低粗糙度,提升反光
          }
          break;
          
        case 'normal': // 普通模型(木头/石头)
          // 无需额外配置,保持默认
          break;
      }
      
      // 关键:更新材质(确保配置生效)
      mat.needsUpdate = true;
    });
  });
}
3.2 自动聚焦相机函数(避免模型看不到)

计算模型包围盒,自动调整相机位置和视角,适配任意尺寸模型:

/**
 * 自动聚焦模型,让相机对准模型中心
 * @param {THREE.Object3D} model - 模型节点
 * @param {THREE.Camera} camera - 相机对象
 * @param {THREE.OrbitControls} controls - 轨道控制器
 */
function autoFocusCamera(model, camera, controls) {
  // 1. 计算模型的包围盒(包含所有子节点)
  const box = new THREE.Box3().setFromObject(model);
  // 2. 获取模型中心坐标
  const center = box.getCenter(new THREE.Vector3());
  // 3. 获取模型尺寸(对角线长度)
  const size = box.getSize(new THREE.Vector3()).length();
  // 4. 计算相机距离(模型尺寸的2倍,保证完整显示)
  const distance = size * 2;
  
  // 5. 设置相机位置(在模型中心后方)
  camera.position.copy(center).add(new THREE.Vector3(0, 0, distance));
  // 6. 相机看向模型中心
  camera.lookAt(center);
  // 7. 更新轨道控制器目标(让控制器围绕模型中心旋转)
  controls.target = center;
  controls.update(); // 生效
}

三、不同类型GLTF模型加载示例

1. 示例1:加载植物/草模型(核心适配透明)

// 创建加载器
const loader = new GLTFLoader();
loader.load(
  './models/grass_medium_01_1k.gltf',
  (gltf) => {
    scene.add(gltf.scene);
    // 配置植物材质(alphaTest+双面)
    configureMaterialForModel(gltf.scene, 'plant');
    // 自动聚焦
    autoFocusCamera(gltf.scene, camera, controls);
  },
  (xhr) => console.log(`加载中:${(xhr.loaded/xhr.total*100).toFixed(1)}%`),
  (err) => console.error('加载失败:', err)
);

2. 示例2:加载金属/高反射模型(调整曝光+粗糙度)

// 先调整渲染器曝光(金属模型适配)
renderer.toneMappingExposure = 2.2;

// 加载金属模型
const loader = new GLTFLoader();
loader.load(
  './models/metal_car.gltf',
  (gltf) => {
    scene.add(gltf.scene);
    // 配置金属材质(降低粗糙度)
    configureMaterialForModel(gltf.scene, 'metal');
    // 自动聚焦
    autoFocusCamera(gltf.scene, camera, controls);
  },
  null,
  (err) => console.error('加载失败:', err)
);

3. 示例3:加载带动画的GLTF模型(扩展)

// 额外导入动画播放器
import { AnimationMixer } from 'three';

let mixer; // 动画混合器
const loader = new GLTFLoader();
loader.load(
  './models/animated_character.gltf',
  (gltf) => {
    scene.add(gltf.scene);
    autoFocusCamera(gltf.scene, camera, controls);
    
    // 初始化动画混合器(播放模型动画)
    mixer = new AnimationMixer(gltf.scene);
    // 播放第一个动画(如有多个,可遍历gltf.animations)
    if (gltf.animations.length > 0) {
      mixer.clipAction(gltf.animations[0]).play();
    }
  },
  null,
  (err) => console.error('加载失败:', err)
);

// 动画循环中更新混合器
function animate() {
  requestAnimationFrame(animate);
  if (mixer) mixer.update(0.01); // 传入时间增量
  controls.update();
  renderer.render(scene, camera);
}
animate();

四、完整实战代码(适配所有模型类型)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Three.js 加载GLTF模型完整示例</title>
  <style>
    body { margin: 0; overflow: hidden; }
    #progress { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); color: white; font-size: 16px; }
  </style>
</head>
<body>
  <div id="progress">加载中... 0.0%</div>
  <script type="module">
    // 1. 导入核心模块
    import * as THREE from 'https://esm.sh/three@0.174.0';
    import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';
    import { GLTFLoader } from 'https://esm.sh/three@0.174.0/examples/jsm/loaders/GLTFLoader.js';

    // 2. 创建三大核心
    const scene = new THREE.Scene();
    //背景色改成灰色
    scene.background = new THREE.Color(0x444444); 
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(window.innerWidth, window.innerHeight);
    // 核心渲染配置(必加)
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 2.0; // 通用曝光值
    document.body.appendChild(renderer.domElement);

    // 3. 添加光源(PBR模型必须加)
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); // 环境光
    const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); // 平行光(主光源)
    dirLight.position.set(5, 8, 5);
    dirLight.castShadow = true; // 可选:开启阴影
    scene.add(ambientLight, dirLight);

    // 4. 创建轨道控制器
    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // 阻尼顺滑
    controls.dampingFactor = 0.05;

    // 5. 核心辅助函数
    // 5.1 材质配置函数
    function configureMaterialForModel(root, type = 'normal') {
      root.traverse((child) => {
        if (!child.isMesh) return;
        const material = child.material;
        const materials = Array.isArray(material) ? material : [material];
        
        materials.forEach(mat => {
          mat.side = THREE.DoubleSide; // 双面渲染
          if (type === 'plant') {
            mat.alphaTest = 0.5; // 植物Alpha裁剪
          } else if (type === 'metal') {
            if (mat.roughness !== undefined) mat.roughness = 0.1;
          }
          mat.needsUpdate = true;
        });
      });
    }

    // 5.2 自动聚焦函数
    function autoFocusCamera(model, camera, controls) {
      const box = new THREE.Box3().setFromObject(model);
      const center = box.getCenter(new THREE.Vector3());
      const size = box.getSize(new THREE.Vector3()).length();
      const distance = size * 2;
      camera.position.copy(center).add(new THREE.Vector3(0, 0, distance));
      camera.lookAt(center);
      controls.target = center;
      controls.update();
    }

    // 6. 加载GLTF模型(替换为你的模型路径)
    const progressDom = document.getElementById('progress');
    const loader = new GLTFLoader();
    loader.load(
      // 示例:使用CDN植物模型(避免本地路径问题)
      './grass_medium_01_1k/grass_medium_01_1k.gltf',
      (gltf) => {
        progressDom.textContent = '加载完成!';
        scene.add(gltf.scene);
        // 配置植物材质
        configureMaterialForModel(gltf.scene, 'plant');
        // 自动聚焦
        autoFocusCamera(gltf.scene, camera, controls);
      },
      (xhr) => {
        const progress = (xhr.loaded / xhr.total) * 100;
        progressDom.textContent = `加载中... ${progress.toFixed(1)}%`;
      },
      (err) => {
        progressDom.textContent = '加载失败!';
        console.error('模型加载失败:', err);
      }
    );

    // 7. 动画循环
    function animate() {
      requestAnimationFrame(animate);
      controls.update(); // 更新控制器
      renderer.render(scene, camera);
    }
    animate();

    // 8. 窗口适配
    window.addEventListener('resize', () => {
      camera.aspect = window.innerWidth / window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    });
  </script>
</body>
</html>

示例效果

46d6f02a-b7f8-42ac-86b7-6cc382d026d0.png

  1. 页面显示加载进度,完成后显示植物模型;
  2. 植物模型透明区域正常显示,无黑边/闪烁;
  3. 相机自动对准模型中心,支持鼠标旋转/缩放视角;
  4. 模型色彩自然,无偏色、过亮问题;
  5. 窗口缩放时,场景自动适配尺寸。

五、常见问题与避坑指南

1. 模型加载失败

问题现象 原因 解决方法
控制台报404 模型路径错误 检查路径(相对路径/绝对路径),确保.gltf/.glb文件存在
跨域错误 本地直接打开HTML,无HTTP服务 用VSCode Live Server/Node.js服务启动项目
CORS错误 CDN/服务器未配置跨域 配置服务器CORS头(Access-Control-Allow-Origin: *)

2. 模型显示异常

问题现象 原因 解决方法
模型偏色/发灰 未设置outputColorSpace 添加 renderer.outputColorSpace = THREE.SRGBColorSpace
模型过亮/过曝 高反射材质未调整曝光 增大 toneMappingExposure(1.5~2.5)
模型透明区域黑边 植物模型未开alphaTest 设置 mat.alphaTest = 0.5
模型背面不可见 未开双面渲染 设置 mat.side = THREE.DoubleSide
模型看不到/太小 相机位置不对 使用 autoFocusCamera 自动聚焦

3. 性能优化

  • 模型轻量化:用Blender简化模型面数,压缩纹理尺寸;
  • 复用材质:避免模型中重复创建相同材质;
  • 关闭不必要功能:静态模型关闭动画、阴影;
  • LOD级别:为复杂模型创建多级别细节(远距显示低模)。

核心总结

  1. 渲染配置核心
    • r152+ 必加 outputColorSpace = THREE.SRGBColorSpace
    • 高反射模型开启 ACESFilmicToneMapping + 调整 toneMappingExposure(1.5~2.5);
  2. 加载流程
    • GLTFLoader.load() → 成功后添加 gltf.scene 到场景;
    • 按模型类型适配材质(植物:alphaTest,金属:调整粗糙度);
    • Box3 计算包围盒,实现相机自动聚焦;
  3. 避坑关键
    • 必须启动HTTP服务,避免跨域;
    • 植物模型开启双面+Alpha裁剪;
    • 不同模型适配不同曝光值,避免过亮/过暗。

scopeId 别再手动捞,可以“反手掏”:Vue3 组件迁移时的样式继承避坑指南

前言

在 Vue3 或 Nuxt3 项目中,为了保证业务平稳,我们经常需要做 “组件渐进式迁移” 。最直观的思路就是通过 v-if/v-else 来动态切换新老组件。

然而,当你满心欢喜地写下切换逻辑后,现实往往会给你一记响亮的耳光:父组件定义的样式(如布局宽度、外边距等)在切换到新组件时突然消失了。  同时,控制台会跳出那个令人头疼的警告:

Extraneous non-props attributes (class) were passed to component but could not be automatically inherited...

今天,我们就来拆解这个关于 Fragment(多根节点)Scoped CSS Hash 与 Nuxt 自动导入组件 纠缠在一起的“深坑”。


一、 案发现场:为什么样式消失了?

在 Vue3 中,Scoped CSS 的原理是给组件的根节点注入一个特殊的属性标识:data-v-xxxx(即 scopeId)。

  1. Fragment 破坏了继承:当你使用 v-if/v-else 切换两个组件时,Vue 会将其视为一个 Fragment(多根节点)。因为“不敢确定”该把父组件的 Hash 挂载到哪个候选节点上,Vue 索性放弃自动继承。
  2. 被屏蔽的 scopeId:你可能会想:“我不依赖自动继承,手动拿到这个 Hash 挂上去总行了吧?” 但你会发现 useAttrs() 里压根没有这个 data-v-hash
  3. Nuxt 的“组件黑盒” :在 Nuxt3 中,很多模块(如 vue3-carousel-nuxt)是自动全局注册的。它们没有显式导出对象,导致你无法直接在 <script> 里引用它们来做组件分发。

二、 曾经的偏门:手动“捞” scopeId

面对困境,很多开发者会尝试从组件实例里强行“捞取”私有属性:

javascript

import { getCurrentInstance } from 'vue';
const instance = getCurrentInstance();
// 强行捞取父组件注入的私有 scopeId
const parentScopeId = instance.vnode.scopeId; 

请谨慎使用此类代码。

然后在模板里手动绑定:

vue

<div v-if="isNew" :[parentScopeId]="''">...</div>

请谨慎使用此类代码。

避坑提醒:虽然 getCurrentInstance 在 uni-app 等小程序开发(获取节点 .in(proxy))中是刚需,但 Vue3 官方文档正有意将其“隐名埋姓”。手动捞取 vnode.scopeId 这种私有属性不仅累,还面临版本升级后属性变更的“崩盘”风险。


三、 破局:反手一“掏”,回归正道

经过实测,最靠谱的方案不是去“补救” Fragment,而是将逻辑上的“碎片化根节点”还原为“动态单根节点”

  1. 反手掏:利用 resolveComponent

既然 Nuxt 自动导入了组件但没给我导出对象,我们可以利用 Vue3 官方提供的运行时寻址 API —— resolveComponent

javascript

import { resolveComponent, computed } from 'vue';

// 动态获取那些“没被包显式导出”的全局组件引用
const NewCarousel = resolveComponent('Carousel'); 
const OldCarousel = resolveComponent('OldCarousel');

const ActiveCarousel = computed(() => (isNew ? NewCarousel : OldCarousel));

请谨慎使用此类代码。

  1. 重构渲染树

抛弃 v-if/v-else,回归内置的 <component :is>

vue

<template>
  <!-- 
    在 Vue3 中,<component :is> 承载的动态组件被视为一个逻辑上的 Single Root(单根节点)。
    此时,父组件的 Hash 样式会自动、精准地注入,无需任何 hack 操作。
  -->
  <component :is="ActiveCarousel" v-bind="$attrs">
    <slot />
  </component>
</template>

请谨慎使用此类代码。


四、 深度总结:顺应框架的本能

通过这次实操,我总结了两个核心认知:

  1. API 的层级性getCurrentInstance 虽然强大,但在业务逻辑中应被视为“最后一道防线”。与其通过私有属性去“偷”那个消失的 Hash,不如利用官方标准的 resolveComponent 夺回组件的引用权。
  2. 单根节点的力量:在处理 Scoped 样式继承时,动态组件占位符(component :is)的优先级和稳定性远高于模板指令(v-if/v-else)。

手动“捞” ,是与框架的内部实现对抗;反手“掏” ,是顺应 Vue 3 的渲染机制本能。在复杂的 Nuxt3 架构下,这才是实现组件无感迁移的最优解。


:如果你也遇到了 Vue3 样式继承失效的“灵异事件”,或者正在为 Nuxt 组件库没有导出而苦恼,希望这个方案能帮你少走弯路。欢迎在评论区一起探讨 Vue 3 的底层黑科技!

JS 模块module

1. module 的核心含义

type="module" 是 HTML 给 <script> 标签新增的一个属性值,它告诉浏览器:这个脚本文件要按照 ES 模块(ES Module,简称 ESM)的规范来解析和执行,而不是按照传统的普通脚本(script)方式执行。

简单来说,module 就是把脚本标记为 “ES 模块”,让浏览器启用 ES 模块的所有特性,这是现代前端模块化开发的基础。

2. 普通脚本 vs module 脚本的核心区别

为了让你更清楚,先对比传统脚本和模块脚本的关键差异:

特性 普通脚本 (<script>) 模块脚本 (<script type="module">)
作用域 全局作用域 模块私有作用域(变量不污染全局)
导入导出 不支持 import/export 支持 import/export 语法
执行时机 解析到就执行 等待 HTML 解析完成后执行(同 defer
严格模式 需手动声明 'use strict' 自动启用严格模式
重复加载 多次执行 只加载 / 执行一次

3. 代码示例:直观理解 module 的作用

示例 1:模块脚本的基础使用

假设有两个文件:

  • utils.js(模块文件)

运行

// 导出一个函数(ES 模块语法)
export function sayHello(name) {
  return `Hello, ${name}!`;
}

// 模块内的变量,不会污染全局
const moduleVar = "我是模块私有变量";
  • index.html(引入模块)

预览

<!DOCTYPE html>
<html>
<body>
  <!-- 以 module 方式引入脚本,才能使用 import/export -->
  <script type="module">
    // 导入模块中的函数(只有 type=module 才支持)
    import { sayHello } from './utils.js';
    
    console.log(sayHello('张三')); // 输出:Hello, 张三!
    // 无法访问 moduleVar,因为它是模块私有变量
    // console.log(moduleVar); // 报错:moduleVar is not defined
  </script>

  <!-- 普通脚本,不支持 import/export -->
  <script>
    // import { sayHello } from './utils.js'; // 报错:Unexpected token 'import'
    const globalVar = "我是全局变量";
  </script>
</body>
</html>

示例 2:模块脚本的自动严格模式

预览

<script type="module">
  // 自动启用严格模式,无需手动写 'use strict'
  undeclaredVar = 123; // 报错:undeclaredVar is not defined(严格模式下未声明变量不能赋值)
</script>

<script>
  // 普通脚本默认非严格模式
  undeclaredVar = 123; // 不报错,undeclaredVar 成为全局变量
</script>

总结

  1. type="module" 中的 module 本质是告诉浏览器:该脚本是 ES 模块,需按 ESM 规范解析执行。
  2. 模块脚本的核心特性:支持 import/export、私有作用域、自动严格模式、延迟执行(同 defer)。
  3. 普通脚本无这些特性,变量会污染全局,也无法使用 ES 模块的导入导出语法。

简单来说,加了 type="module",你的 JS 代码就拥有了现代模块化开发的能力,这也是目前前端项目中最常用的脚本执行方式。

react的Scheduler源码解析

react的Scheduler源码解析

react版本:18.3.1

位置:packages\scheduler\src\forks\Scheduler.js

是什么?

对不同优先级任务进行调度,使高优先级先执行,低优先级后执行。

解决的问题?

解决页面卡顿问题,js单线程,大任务会阻塞浏览器的绘制,导致页面卡顿。

怎么解决的?

把任务分片,在浏览器空余时间去执行,通过优先级分配顺序

怎么使用?

const { unstable_scheduleCallback } = require("scheduler")
const tasks = [1,1,2,2,3,3,4,4,1,2,3,4,1,2,3,4,3,2,1,1,1,1,1]

tasks.forEach((priority , i) => {
  unstable_scheduleCallback(priority , ()=>{
    console.log(`优先级${priority}` , `第${i}任务`)
  })
})
console.log("同步任务")
Promise.resolve().then(res => console.log("微任务"))

// 输出:
// 同步任务
// 微任务
// 优先级1,第0个任务
// 优先级1,第1个任务
// 优先级1,第8个任务
// 优先级1,第12个任务
// 优先级1,第18个任务
// 优先级1,第19个任务
// 优先级1,第20个任务
// 优先级1,第21个任务
// 优先级2,第2个任务
// 优先级2,第3个任务
// 优先级2,第9个任务
// 优先级2,第13个任务
//  ...

Scheduler中的优先级


export const ImmediatePriority = 1;     // 超时优先级:需要立即执行的任务       
export const UserBlockingPriority = 2;  // 用户阻塞优先级:用户交互相关任务    
export const NormalPriority = 3;        // 普通优先级:常规更新任务         
export const LowPriority = 4;           // 低优先级:可延迟执行的任务         
export const IdlePriority = 5;          // 空闲优先级:在空闲时执行的任务   

Scheduler中的全局变量

var maxSigned31BitInt = 1073741823;

// 不同优先级对应时间,会和当前时间相加,计算出过期时间点
var IMMEDIATE_PRIORITY_TIMEOUT = -1;

var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;

var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

var taskQueue = [];  // 立即执行任务队列
var timerQueue = [];  // 延迟执行任务队列

var taskIdCounter = 1; // 任务ID计数器

var isSchedulerPaused = false;

var currentTask = null;  // 当前正在执行的任务对象
var currentPriorityLevel = NormalPriority;  // 当前调度器优先级

var isPerformingWork = false;  // 是否正在执行工作循环

var isHostCallbackScheduled = false;  // 是否已安排工作循环调度
var isHostTimeoutScheduled = false;  // 是否已安排延迟任务检查

小顶堆

本质就是一棵二叉树结构,这里用数组模拟的,它的堆顶永远维持着最小值(最高任务),对外暴露3个方法,push 往堆中塞入一个元素,pop 弹出堆顶元素,peek获取堆顶元素

用节点的 sortIndex作为判断依据 ,如果比较不了,就是用ID,也就是顺序了

function compare(a, b) {
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

Scheduler暴露出的api

export {
  ImmediatePriority as unstable_ImmediatePriority,
  UserBlockingPriority as unstable_UserBlockingPriority,
  NormalPriority as unstable_NormalPriority,
  IdlePriority as unstable_IdlePriority,
  LowPriority as unstable_LowPriority,
  unstable_runWithPriority,
  unstable_next,
  unstable_scheduleCallback,  // 入口方法,开启调度
  unstable_cancelCallback,    // 取消任务
  unstable_wrapCallback,// 包裹任务
  unstable_getCurrentPriorityLevel,
  shouldYieldToHost as unstable_shouldYield,
  unstable_requestPaint,
  unstable_continueExecution,
  unstable_pauseExecution,
  unstable_getFirstCallbackNode,
  getCurrentTime as unstable_now,  // 获取当前时间戳
  forceFrameRate as unstable_forceFrameRate,
};

入口函数

根据优先级计算出当前任务的过期时间。

创建当前任务对象

过期时间和当前时间戳比较,延时任务加入到timerQueue,其他任务加入taskQueue

当前任务为延时,开启定期检查timerQueue中任务的定时器

当前任务为task任务,开启任务调度

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime(); // 获取当前时间

  var startTime; // 计算任务开始时间,注意delay参数,如果有delay参数,则表示当前任务为延时任务,会加入timerQueue中
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
  } else {
    startTime = currentTime;
  }

  var timeout;
  switch (priorityLevel) { // 根据优先级 计算任务过期时间
    case ImmediatePriority:
      timeout = IMMEDIATE_PRIORITY_TIMEOUT;
      break;
    case UserBlockingPriority:
      timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
      break;
    case IdlePriority:
      timeout = IDLE_PRIORITY_TIMEOUT;
      break;
    case LowPriority:
      timeout = LOW_PRIORITY_TIMEOUT;
      break;
    case NormalPriority:
    default:
      timeout = NORMAL_PRIORITY_TIMEOUT;
      break;
  }

  var expirationTime = startTime + timeout;  // 计算任务过期时间

  var newTask = {  // 创建任务对象
    id: taskIdCounter++, // 任务id,自增
    callback, // 任务本身回调函数
    priorityLevel, // 任务优先级
    startTime,      // 任务开始时间
    expirationTime,   // 任务过期时间
    sortIndex: -1,      // 用于小顶堆排序的索引,会用来计算优先级,会被赋值expirationTime
  };

  if (startTime > currentTime) { // 延时任务,加入timerQueue中
      newTask.sortIndex = startTime;
      push(timerQueue, newTask);

      cancelHostTimeout(); // 取消定期检查timerQueue
      requestHostTimeout(handleTimeout, startTime - currentTime); // 开启定期检查timerQueue中的任务,检查是否到期
    }
  } else {
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);  // 加入到 taskQueue
    requestHostCallback(flushWork); // 开启任务调,传入flushWork
  }

  return newTask;
}

通过Scheduler调度的任务,都会在下一个宏任务中去执行,schedulePerformWorkUntilDeadline的定义会根据环境情况去实现

依次检查setImmediate,MessageChannel,setTimeout是否可用

function requestHostCallback(callback) {
  scheduledHostCallback = callback;  //  注意,这是全局变量,callback为flushWork
  
  // 开启任务调度循环, 此函数会根据环境选择合适的异步调度API,实现宏任务
  schedulePerformWorkUntilDeadline();   
}

let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') { // setImmediate
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') { // MessageChannel
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {                                            // setTimeout
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

循环执行taskQueue中的任务

会判断任务池中的任务

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {  // 判断当前任务是否为空
    const currentTime = getCurrentTime();
    
    //全局的startTime,用来记录当前这批任务的调度开始时间,用来判断是否用完切片用的。
    startTime = currentTime; 
    const hasTimeRemaining = true;
    let hasMoreWork = true;
    try {
      // 执行flushWork
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);   
    } finally {
        // 如果task队列中还有,继续在下一个宏任务中调度
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline(); 
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
  needsPaint = false;
};
function flushWork(hasTimeRemaining, initialTime) {
  isHostCallbackScheduled = false; // 重置调度状态,防止重复调度

  // 如果有定时检查timeQueue,取消检查定时器
  if (isHostTimeoutScheduled) {
    isHostTimeoutScheduled = false;
    cancelHostTimeout();           
  }

  isPerformingWork = true; // 标记正在执行工作循环中
  const previousPriorityLevel = currentPriorityLevel; // 保存当前优先级
  try {
      // ...
      return workLoop(hasTimeRemaining, initialTime);
   
  } finally {
    currentTask = null; // 清理当前任务引用 - 当前任务已完成或暂停
    currentPriorityLevel = previousPriorityLevel; // 恢复原始优先级
    isPerformingWork = false; // 标记 工作循环已完成
  }
}
function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  advanceTimers(currentTime); // 检查timerQueue中是否有延时任务
  currentTask = peek(taskQueue);  // 取出第一个任务,最高优先级

  
  while (currentTask !== null) {

    // 如果 expriationTime > currentTime 说明任务还没有过期,
    // 过期任务,不会再调用,因为调度是宏任务
    // shouldYieldToHost 判断浏览器是否还有空余时间(5ms),没有就跳出本次循环,下个宏任务执行
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      break;
    }
    
    const callback = currentTask.callback;
    if (typeof callback === 'function') {
      currentTask.callback = null;   // 先置空任务,防止重复调用
      currentPriorityLevel = currentTask.priorityLevel;
      // 判断当前任务是否过期,chuandaocallback中,供用户使用
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; 

      // 执行任务,可能有返回的新任务
      const continuationCallback = callback(didUserCallbackTimeout);
      
      // 任务未完成,继续赋值回调函数
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;  
      } 
        
      // 任务完成了,删除任务
      else {
        if (currentTask === peek(taskQueue)) {  
          pop(taskQueue);
        }
      }
      advanceTimers(currentTime);  // 执行完一个任务,就去检查timerQueue中是否有延时任务
    } else {
      pop(taskQueue);
    }
    
    currentTask = peek(taskQueue); // 下次循环
  }
  
 
  // 如果taskQueue还有任务,返回true,表示还有任务未完成, 需要继续调度
  if (currentTask !== null) {  
    return true;
  } else { // taskQueue已空,检查timerQueue中是否有延时任务
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); // 开定时器,执行handleTimeout
    }
    return false;
  }
}

advanceTimers用来检查timerQueue中是否有到期的延时任务,如果有则转移到taskQueue中

这个函数会在workLoop开始循环前调用,在一个任务执行结束后调用。

并且也会开始定时器,定时调用

  // 循环检查timerQueue中是否有到期的延时任务,如果有则转移到taskQueue中
function advanceTimers(currentTime) { 
  
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {  // 检查是否过期了
      
      pop(timerQueue); // 从timerQueue删除当前任务
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer); // 加入到 taskQueue
    
    } 
    
    timer = peek(timerQueue);
  }
}

检查timerQueue

当没有开启调度,并且taskQueue有任务,就开启调度

当taskQueue没有任务,timerQueue有任务就再开启定时器,检查timerQueue

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  advanceTimers(currentTime);

  if (!isHostCallbackScheduled) {
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);  // 开启调度
    } else {
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime); // 延时检查
      }
    }
  }
}

shouldYieldToHost用来判断浏览器空余时间是否用完,是否要退出调度

var frameInterval = frameYieldMs;
function shouldYieldToHost() {
  var timeElapsed = exports.unstable_now() - startTime;
  if (timeElapsed < frameInterval) {
    return false; // 说明不应该中断,时间片还没有用完
  } 
  return true; // 说明时间片已经用完了
}

Vue 必学:Composition/Options API 选型指南+组合式函数最佳实践

在 Vue 生态中,Options API 和 Composition API 是两种核心的代码组织方式。Options API 作为 Vue 2 的默认 API,凭借直观的选项划分降低了新手入门门槛;而 Composition API 则在 Vue 3 中推出,以逻辑组合为核心,解决了大型项目中的代码复用与维护难题。本文将系统对比二者的优劣势,并深入探讨自定义组合式函数(Composables)的最佳实践、命名规范与类型声明,为 Vue 项目开发提供选型与编码参考。

一、Composition API 与 Options API 优劣势对比

二者的核心差异源于代码组织逻辑:Options API 按功能划分选项(如 data、methods、computed),Composition API 按业务逻辑划分代码块,各自适配不同的项目场景。

1.1 Options API 优劣势

优势

  • 入门门槛低,直观易懂:Options API 采用固定的选项结构,data 定义状态、methods 定义方法、computed 定义计算属性,新手能快速理解各部分功能,无需关心代码组织的逻辑关联,上手成本极低。
  • 代码结构规整,约定大于配置:固定的选项划分使代码具有统一的风格,团队协作时无需额外约定,可直接根据选项定位代码位置,适合小型项目或多人快速上手的场景。
  • 兼容 Vue 2 生态,迁移成本低:作为 Vue 2 的默认 API,拥有成熟的生态工具与社区案例,现有 Vue 2 项目可无缝沿用,如需迁移到 Vue 3,Options API 仍可正常使用,无需大规模重构。

劣势

  • 逻辑碎片化,维护成本高:当组件功能复杂时,同一业务逻辑的代码会分散在 data、methods、computed、watch 等多个选项中,形成“碎片化”代码。例如,一个表单提交功能的状态、验证方法、提交逻辑可能分布在不同选项,排查问题时需跨选项跳转,随着代码量增加,维护难度呈指数级上升。
  • 代码复用能力有限:Options API 主要通过混入(Mixins)实现代码复用,但 Mixins 存在明显缺陷:命名冲突风险、逻辑来源不清晰、依赖关系隐式化,多个 Mixins 叠加时,难以追踪状态与方法的归属,排查问题时耗时耗力。
  • 类型推断支持弱:在 TypeScript 中,Options API 的选项式结构难以实现精准的类型推断,需额外通过 Vue.extend 或装饰器补充类型定义,代码冗余且易出现类型错误。

1.2 Composition API 优劣势

优势

  • 逻辑聚合,维护性强:Composition API 允许将同一业务逻辑的状态、方法、计算属性、监听逻辑聚合在一个代码块中(通常通过 setup 函数或
  • 灵活的代码复用:基于逻辑聚合特性,可将通用逻辑封装为组合式函数(Composables),在多个组件中复用。与 Mixins 不同,组合式函数的逻辑来源清晰,无命名冲突风险,且支持传递参数实现逻辑定制,复用能力更强大、灵活。
  • 出色的 TypeScript 支持:Composition API 天生适配 TypeScript,setup 函数、响应式 API(如 ref、reactive)均可实现精准的类型推断,无需额外冗余代码,能充分发挥 TypeScript 的类型校验能力,减少运行时错误。
  • 逻辑拆分与组合更灵活:支持将复杂逻辑拆分为多个小型逻辑单元,再根据需求组合使用,既保证了单一逻辑的职责清晰,又能灵活适配不同组件的功能需求,适合大型复杂项目。

劣势

  • 入门门槛较高:相比 Options API 固定的选项结构,Composition API 需理解响应式 API(ref、reactive、toRefs 等)、生命周期钩子的写法变化,且需手动组织逻辑结构,新手可能出现逻辑混乱的问题。
  • 代码风格不统一风险:逻辑组织的灵活性可能导致团队内部代码风格差异,若缺乏统一规范,不同开发者的逻辑拆分方式不同,反而降低代码可读性。
  • 小型项目冗余:对于简单组件(如仅展示数据的静态组件),使用 Composition API 会增加代码量(如 ref 包裹状态、return 暴露属性),反而不如 Options API 简洁。

1.3 选型建议

  • 选择 Options API:小型项目、新手团队、Vue 2 迁移项目、组件逻辑简单且无需复用的场景。
  • 选择 Composition API:大型复杂项目、需要大量逻辑复用的场景、使用 TypeScript 开发的项目、组件逻辑需拆分组合的场景。

二、自定义组合式函数(Composables)最佳实践

组合式函数是 Composition API 的核心复用载体,本质是封装通用逻辑的函数,命名通常以“use”开头(如 useRequest、useForm),返回需要暴露的状态与方法。遵循最佳实践可保证组合式函数的可复用性、可维护性与易用性。

2.1 核心原则

  • 单一职责原则:一个组合式函数只封装一项核心逻辑(如 useRequest 仅处理请求逻辑,useForm 仅处理表单逻辑),避免将多个无关逻辑混入同一函数,确保函数体积小、职责清晰,便于复用与维护。
  • 响应式传递:函数内部使用 ref、reactive 创建的响应式状态,需通过 return 暴露给组件,组件可直接使用并响应状态变化;若接收外部参数,需确保参数为响应式对象(或通过 toRefs 转换),避免丢失响应式特性。
  • 无副作用优先:尽量使组合式函数纯函数化,若必须包含副作用(如请求、DOM 操作、定时器),需在函数内部处理副作用的清理(如清除定时器、取消请求),避免内存泄漏。
  • 逻辑隔离:组合式函数内部逻辑应与组件解耦,不依赖组件的实例(如避免使用 this),仅通过参数接收外部依赖,通过返回值提供能力,确保可在任意组件、甚至非组件环境(如 Pinia)中复用。

2.2 实现要点

(1)副作用清理

包含副作用的组合式函数,需使用 onUnmounted、onDeactivated 等生命周期钩子清理副作用。例如,定时器、事件监听、网络请求等,需在组件卸载时销毁,避免内存泄漏。

// useTimer.ts
import { ref, onUnmounted } from 'vue';

export function useTimer(initialDelay = 1000) {
  const count = ref(0);
  let timer: number | null = null;

  // 启动定时器
  const startTimer = () => {
    timer = window.setInterval(() => {
      count.value++;
    }, initialDelay);
  };

  // 停止定时器
  const stopTimer = () => {
    if (timer) {
      window.clearInterval(timer);
      timer = null;
    }
  };

  // 组件卸载时清理定时器
  onUnmounted(() => {
    stopTimer();
  });

  return { count, startTimer, stopTimer };
}

(2)参数可选与默认值

为提高灵活性,组合式函数的参数应支持可选配置,并设置合理默认值,允许组件根据需求覆盖默认配置。

// useRequest.ts
import { ref, onUnmounted } from 'vue';
import axios, { AxiosRequestConfig } from 'axios';

interface UseRequestOptions extends AxiosRequestConfig {
  autoRun?: boolean; // 是否自动触发请求
}

export function useRequest(url: string, options: UseRequestOptions = {}) {
  const { autoRun = true, ...axiosConfig } = options;
  const data = ref(null);
  const loading = ref(false);
  const error = ref(null);

  const fetchData = async () => {
    loading.value = true;
    try {
      const res = await axios.get(url, axiosConfig);
      data.value = res.data;
      error.value = null;
    } catch (err) {
      error.value = err;
      data.value = null;
    } finally {
      loading.value = false;
    }
  };

  // 自动触发请求
  if (autoRun) {
    fetchData();
  }

  return { data, loading, error, fetchData };
}

(3)避免命名冲突

组合式函数返回的状态与方法需命名清晰,避免与组件内部变量、其他组合式函数的返回值重名。可通过前缀、语义化命名区分,例如 useForm 返回的表单状态可命名为 formValue、formErrors,而非 value、errors。

2.3 命名规范

(1)函数命名

  • 必须以“use”开头,遵循驼峰命名法(camelCase),明确标识为组合式函数,便于开发者识别与导入。示例:useRequest、useForm、useScrollPosition。
  • 命名需语义化,准确反映函数封装的逻辑,避免模糊命名。例如,useTimer 比 useUtil 更清晰,useFormValidator 比 useFormCheck 更精准。

(2)文件命名

  • 单个组合式函数的文件,命名与函数名一致,后缀为 .ts(TypeScript)或 .js。示例:useTimer.ts、useRequest.ts。
  • 多个相关组合式函数可放在同一个文件夹下,通过 index.ts 导出,便于批量导入。例如:在 composables/form/ 目录下存放 useForm.ts、useFormValidator.ts,通过 index.ts 聚合导出。

(3)返回值命名

  • 返回的状态与方法需语义化,与函数封装的逻辑强关联。例如,useScrollPosition 返回 scrollX、scrollY(滚动坐标)、updateScrollPosition(更新坐标方法)。
  • 避免使用简写、模糊词汇,如不用 val 代替 value,不用 handle 代替具体动作(如 submit、clear)。

三、组合式函数的类型声明

在 TypeScript 中,合理的类型声明能提升组合式函数的易用性,避免类型错误,同时提供良好的 IDE 提示。以下是常见场景的类型声明方法。

3.1 基础类型声明

对于简单组合式函数,直接通过类型注解声明参数与返回值类型,确保类型精准。

// useCounter.ts
import { ref, Ref } from 'vue';

// 声明参数类型
interface UseCounterOptions {
  initialValue?: number;
  step?: number;
}

// 声明返回值类型
interface UseCounterReturn {
  count: Ref<number>;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export function useCounter(options: UseCounterOptions = {}): UseCounterReturn {
  const { initialValue = 0, step = 1 } = options;
  const count = ref<number>(initialValue);

  const increment = () => {
    count.value += step;
  };

  const decrement = () => {
    count.value -= step;
  };

  const reset = () => {
    count.value = initialValue;
  };

  return { count, increment, decrement, reset };
}

3.2 泛型类型声明

当组合式函数需适配多种数据类型时,使用泛型(Generic)声明,提高函数的灵活性与复用性。例如,封装一个通用的列表请求函数,支持不同类型的列表数据。

// useList.ts
import { ref, Ref } from 'vue';
import axios from 'axios';

interface UseListOptions<T> {
  url: string;
  autoRun?: boolean;
  formatData?: (rawData: any) => T[]; // 数据格式化函数
}

interface UseListReturn<T> {
  list: Ref<T[]>;
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  fetchList: () => Promise<void>;
}

export function useList<T = any>(options: UseListOptions<T>): UseListReturn<T> {
  const { url, autoRun = true, formatData = (raw) => raw.data } = options;
  const list = ref<T[]>([]) as Ref<T[]>;
  const loading = ref<boolean>(false);
  const error = ref<Error | null>(null);

  const fetchList = async () => {
    loading.value = true;
    try {
      const res = await axios.get(url);
      list.value = formatData(res.data);
      error.value = null;
    } catch (err) {
      error.value = err as Error;
      list.value = [];
    } finally {
      loading.value = false;
    }
  };

  if (autoRun) {
    fetchList();
  }

  return { list, loading, error, fetchList };
}

使用时可指定具体类型,获得精准的类型提示:

// 声明列表项类型
interface User {
  id: number;
  name: string;
  age: number;
}

// 使用泛型组合式函数
const { list, loading } = useList<User>({
  url: '/api/users',
  formatData: (raw) => raw.users // 类型校验:确保返回 User[] 类型
});

// list 自动推断为 Ref<User[]>,IDE 提供 User 属性提示
list.value.forEach(user => {
  console.log(user.name);
});

3.3 响应式类型处理

组合式函数中常用 ref、reactive 创建响应式状态,类型声明需注意以下几点:

  • ref 类型:通过 ref(initialValue) 声明,若初始值为 null/undefined,需明确类型(如 ref<User | null>(null))。
  • reactive 类型:直接为 reactive 传递接口类型,例如 const form = reactive({ name: '', age: 0 })。
  • toRefs 类型:当需解构 reactive 对象时,使用 toRefs 保持响应式,类型自动继承原对象类型,例如 const { name, age } = toRefs(form),name 自动推断为 Ref。

四、总结

Options API 与 Composition API 并非对立关系,而是适配不同场景的技术方案:Options API 适合简单场景与新手入门,Composition API 则更擅长解决大型项目的逻辑复用与维护问题。自定义组合式函数作为 Composition API 的核心复用载体,需遵循单一职责、响应式传递、副作用清理等原则,配合规范的命名与精准的类型声明,才能充分发挥其灵活性与可复用性。

在实际开发中,建议根据项目规模、团队技术栈(是否使用 TypeScript)、逻辑复杂度选择合适的 API 方案,并制定统一的组合式函数开发规范,提升团队协作效率与代码质量。

LeetCode 15|三数之和:从暴力到双指针,一次性把“去重”讲清楚

三数之和(Three Sum)是一个非常经典、非常容易写错、也非常适合训练思维的题。

很多人第一次做这题的状态是:

  • 思路能想出来
  • 代码能跑
  • 结果一堆重复答案,直接 GG

这篇笔记,我会围绕一个核心目标来讲:

为什么一定要排序 + 双指针?
去重到底在去什么?


一、题目回顾

给你一个整数数组 nums,判断是否存在三个元素 a, b, c,使得:

a + b + c = 0

返回所有不重复的三元组

注意关键词:
不重复,这是这道题的灵魂。


二、为什么暴力解法不行?

最直观的写法是三层循环:

i + j + k == 0

问题有两个:

  1. 时间复杂度是 O(n³),数组稍微大一点就超时
  2. 完全没法优雅地去重

所以这道题的正确打开方式只有一条路:

先排序,再用双指针


三、排序的真正意义

排序不是为了好看,而是为了两件事:

  1. 让双指针成立
  2. 让去重变得可能

排序之后,数组满足:

nums[i] <= nums[left] <= nums[right]

这会带来一个非常重要的性质:

  • 当前和小了,只能让 left 右移
  • 当前和大了,只能让 right 左移

这是双指针成立的数学基础。


四、整体思路拆解

整体逻辑可以拆成三层:

  1. 固定第一个数 i
  2. i 右侧,用双指针找 left + right = -nums[i]
  3. 在三个层面上去重

五、完整代码

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        Arrays.sort(nums);

        int n = nums.length;
        for (int i = 0; i < n - 2; i++) {

            // 第一层去重:固定数 nums[i]
            if (i > 0 && nums[i] == nums[i - 1]) {
                continue;
            }

            int left = i + 1;
            int right = n - 1;

            while (left < right) {
                int sum = nums[i] + nums[left] + nums[right];

                if (sum == 0) {
                    res.add(Arrays.asList(nums[i], nums[left], nums[right]));

                    // 第二层去重:left
                    while (left < right && nums[left] == nums[left + 1]) {
                        left++;
                    }

                    // 第三层去重:right
                    while (left < right && nums[right] == nums[right - 1]) {
                        right--;
                    }

                    left++;
                    right--;
                } else if (sum < 0) {
                    left++;
                } else {
                    right--;
                }
            }
        }
        return res;
    }
}

六、三层去重,逐层讲清楚

这是这道题最容易写错、也最值得理解的部分

1. 第一层去重:i 不能重复

if (i > 0 && nums[i] == nums[i - 1]) {
    continue;
}

含义是:

  • 如果当前固定的数和上一次固定的一样
  • 那后面的双指针结果一定也一样
  • 直接跳过,避免重复三元组

这是在防止这种情况:

[-1, -1, 0, 1]
 ↑   ↑
 i   i+1

2. 第二层去重:left 去重

while (left < right && nums[left] == nums[left + 1]) {
    left++;
}

当我们已经找到一个合法解:

nums[i] + nums[left] + nums[right] == 0

如果 left 指向的值后面还是一样的数:

... 0, 0, 0 ...
     ↑  ↑

继续用它只会得到一模一样的三元组

所以必须跳过。


3. 第三层去重:right 去重

while (left < right && nums[right] == nums[right - 1]) {
    right--;
}

逻辑和 left 完全对称。

你可以把这两层理解为一句话:

当前解已经用过了,这一整段相同的数都不再有价值


七、为什么要最后再 left++ / right--

left++;
right--;

前面的 while 只是“跳过重复值”,
但当前这对 (left, right) 已经用过了,必须整体向中间推进,继续找新解。


八、时间与空间复杂度

  • 时间复杂度:O(n²)

    • 外层一个 i
    • 内层双指针线性扫描
  • 空间复杂度:O(1)(不算结果集)

这是这道题能做到的最优解法。


九、总结一句话版

  • 排序是为了双指针和去重
  • 固定一个数,其余两个用左右指针夹逼
  • 去重一定要分三层,缺一不可

如果你能把**“为什么要去重、去的是什么重”**讲清楚,
那这道题你就真的吃透了。

零信任编程:如何设计一套 AI 无法逃逸的“AI 逻辑沙盒”?

前言

在上一篇文章《从 Vibe Coding 到责任归属》中,我们达成了一个共识:AI 不坐牢,责任人承担。

既然责任无法规避,那么作为架构师,我们唯一能做的就是:像约束 Docker 容器一样,约束 AI 产出的代码。

今天,我将分享一套名为 「AI 逻辑沙盒 (AI Logic Sandbox)」 的工程治理方案。它的核心逻辑不是靠“嘴”去命令 AI,而是通过编译时授权静态隔离,从物理上锁死 AI 代码的破坏半径。


一、 核心痛点:AI 的“先验知识”逃逸

为什么传统的 Prompt 约束总是失效?
因为 AI(如 Claude 或 GPT)拥有庞大的先验知识。即便你在 Prompt 里写了“不要使用原生 Fetch”,但它知道这段代码运行在浏览器环境。一旦逻辑陷入复杂,它会本能地调用 windowlocalStorage 甚至 document.cookie

只要环境是开放的,AI 就会存在“逻辑逃逸”。

为了解决这个问题,我们需要在项目中开辟一个特殊的目录 —— AI 逻辑沙盒区。在这个区域内,AI 的“上帝视角”将被剥夺,它只能看到你允许它看到的世界。


二、 方案设计:基于“契约”的双层宏架构

为了实现这种颗粒度的控制,我设计了一套名为 Define-Apply 的双层编译宏体系。它将权限控制从“运行时”提前到了“开发态”。

  1. 宿主层:定义权限边界(Define)

在沙盒的入口处,由架构师(人类)显式定义该模块的权限:

typescript

// 宿主定义:这个 AI 模块能动什么?
defineEnvContext<{
  // 授权事件监听:必须成对出现,确保 AI 能够清理副作用,避免内存泄露
  window: Pick<Window, 'addEventListener' | 'removeEventListener'>, 
  document: Pick<Document, 'getElementById'>
}>();

defineImportContext<{
  debounce: typeof import('lodash/debounce') // 仅允许引入特定的工具函数
}>();

请谨慎使用此类代码。

注意:  这里我们同时 Pick 出了移除监听的权限。在沙盒模式下,架构师必须通过 API 声明强制 AI 关注副作用的闭环。如果你不给 AI 移除监听的权限,它写出的代码就无法通过沙盒内部的工程审计。

  1. AI 侧:申请能力接入(Apply)

在逻辑沙盒内部,AI 编写的代码必须通过以下宏来获取能力:

typescript

// AI 使用:我行使被授予的能力
const { window, document } = applyEnvContext<GlobalApi>();
const { debounce } = applyImportContext<ImportModules>();

// AI 之后编写的逻辑将被锁死在上述解构出的变量中

请谨慎使用此类代码。


三、 物理沙箱:如何实现“环境抹除”?

仅仅有宏是不够 determined,我们需要利用 TypeScript 和 ESLint 的层级配置特性,为沙盒构建“边界”。

  1. 类型层面的“环境盲盒”

我们在沙盒文件夹下放置一个定制的 tsconfig.json。通过配置 lib: ["ESNext"] 并移除 "DOM" 库,我们从类型系统层面抹除了 window 和 document 的存在。

  • 结果:AI 尝试直接写 window.location 时,IDE 会直接报红。它必须通过 applyEnvContext 来获取那份被“阉割”过的环境对象。
  1. 语法层面的“铁丝网”

通过特定的 ESLint 规则,我们强制执行以下约束:

  • 根规则覆盖:设置 root: true,切断父级目录的宽松配置。
  • 零全局变量:开启 no-undef,且禁止任何未经宏声明的外部 import
  • 禁止逃逸:拦截任何尝试通过原生 require 或动态 import() 探测项目隐私的行为。

四、 自动化映射:从“声明”到“拦截”

这套方案最高效的地方在于:配置是自动生成的。

我设计了一个映射脚本(Mapping Script)  ,它会静态扫描宿主侧的 define 宏:

  1. 解析泛型:提取出你 Pick 出来的属性名。
  2. 同步配置:自动将这些属性填入该文件夹下的 .eslintrc.js 白名单中。
  3. 动态构建:自动生成一个受限的 .d.ts 类型定义文件,供该目录下的 TS 引擎使用。

这意味着:架构师只需要修改一行 TS 接口定义,整个 AI 逻辑沙盒的安全边界就会自动完成“扩容”或“收缩”。


结语:将权力关进笼子

「AI 逻辑沙盒」的本质,是把原本模糊的“代码审查”变成了清晰的  “权限审批”  。

  • 你不需要读懂 AI 内部的每一行循环。
  • 你只需要审批它申请的 defineImportContext 是否合规。
  • 只要编译通过,就代表这份代码的风险已经被物理性地限制在了你设定的方寸之间。

这是我们应对 2026 年大规模 AI 协作的底层防线。在下一篇实战篇中,我将深入底层,分享如何编写这个自动化映射脚本,以及如何通过 SWC/Babel 插件在构建时处理这些宏。

欢迎关注专栏 [《AI 原生工程:逻辑沙盒与零信任代码治理》],我们下一篇聊聊“自动化拦截”的技术落地。


讨论点:
如果是你,你会给 AI 开放哪些“危险”权限?这种基于“编译宏”的隔离思路,是否能解决你对 AI 代码失控的担忧?欢迎评论区见。

如何从零用WebGPU渲染二次元MMD人物模型

如何使用 WebGPU 从零开始渲染动漫角色

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_it9bc1oir7p31cuzkqn0.webp

简介

原文: reze.one/tutorial

如果你用过 three.js、babylon.js 这些框架,知道怎么加载模型、设置相机、加光照,但搞不清楚底层到底发生了什么;或者你看了 WebGPU 的 "Hello Triangle" 示例,还是不知道怎么从零开始渲染一个真正的 3D 角色——那这个教程就是为你准备的。

我们会用五个步骤,从最简单的三角形开始,一步步做到一个完整贴图、能动的动漫角色。在这个过程中,你会学到完整的渲染管线:几何缓冲区、相机变换、材质纹理、骨骼动画,还有把它们串起来的渲染循环。

重点不在数学或着色器代码(这些可以交给 AI),而在理解 WebGPU 的思维模型:有哪些组件(缓冲区、绑定组、管线、渲染通道),它们怎么连起来(数据怎么流转),为什么要这样设计(什么时候用 uniform 缓冲区,什么时候用 storage 缓冲区)。最后你会得到一个能跑的渲染器,也能看懂 Reze Engine 这种引擎是怎么做的。完整代码在这里


Engine v0: 你的第一个三角形

先从经典的 Hello Triangle 开始。虽然很基础,但它展示了 WebGPU 管线的所有基本组件。理解了这些组件怎么连起来,后面做复杂模型就只是加数据,不需要学新概念了。

理解 GPU 作为独立的计算单元

高级框架把底层细节都藏起来了,但 WebGPU 需要你直接跟 GPU 打交道。可以把 GPU 想象成另一台电脑,有自己的内存和指令集。不像 JavaScript 里直接传数据给函数,用 GPU 得跨边界通信,你得明确告诉它:

  • 要处理什么:顶点数据
  • 数据在哪:缓冲区(buffer),就是 GPU 内存里的一块区域,类似 ArrayBuffer
  • 怎么处理:着色器和管线,告诉 GPU 怎么转换和渲染
  • 从哪开始:渲染通道,执行渲染的命令队列

简单说,高级框架你操作的是 Mesh、Material、Scene 这些对象,WebGPU 里你操作的是 Buffer、Texture、Pipeline 这些底层资源。

WebGPU 初始化模式

第一个 Engine 类 engines/v0.ts 按标准的 WebGPU 初始化流程来:

  1. 请求 GPU 设备,在画布上创建渲染上下文
  2. 创建 GPU 缓冲区,把 3 个顶点的位置数据写进去(用 writeBuffer
  3. 写着色器:
    • 顶点着色器:处理每个顶点,把 3D 坐标转成屏幕坐标
    • 片段着色器:决定每个像素的颜色
  4. 创建渲染管线:把着色器和缓冲区布局打包在一起
  5. 创建渲染通道:执行管线,在屏幕上画出三角形

顶点缓冲区

顶点缓冲区存的是顶点位置数据。创建步骤:

  • device.createBuffer() 创建缓冲区
  • 指定 GPUBufferUsage.VERTEX 标志
  • device.queue.writeBuffer() 把数据从 CPU 传到 GPU

着色器

着色器用 WGSL 写,在 GPU 上跑:

  • 顶点着色器:每个顶点跑一次,输出屏幕位置
  • 片段着色器:每个像素跑一次,输出颜色

渲染管线

渲染管线把着色器、顶点布局、渲染状态打包在一起,定义:

  • 用哪些着色器
  • 顶点数据格式(位置、法线、UV 等)
  • 片段怎么混合和测试(深度测试、混合模式等)

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_9k2kqp1kxwrcewfqc77w.webp


Engine v1: 添加相机并使其成为 3D

第一个例子只画了一帧静态画面。要让它变成 3D,需要两样东西:相机,还有能连续生成帧的渲染循环。

什么是相机?

在 WebGPU 里,相机不是 3D 对象,是两个变换矩阵(view 和 projection),用来把 3D 世界坐标转成 2D 屏幕坐标,产生深度感。不像高级框架有现成的相机对象,WebGPU 得自己管这些矩阵。

简单说:

  • View 矩阵:相机在哪,朝哪看
  • Projection 矩阵:怎么把 3D 空间投影到 2D 屏幕(透视投影)

矩阵乘法的细节不用管(交给 AI),只要知道这两个矩阵组合起来,就能把 3D 顶点坐标转成屏幕上的 2D 像素位置。

相机类

相机类在 lib/camera.ts。实现细节不用管(交给 AI),只要知道它会算 view 和 projection 矩阵,并且会根据鼠标操作(拖拽、缩放、平移)更新。

关键概念:Uniform 缓冲区

要把相机矩阵从 JavaScript 传到着色器,用 uniform 缓冲区——一块 GPU 内存,所有着色器都能访问,像全局变量一样。

先把相机数据写进缓冲区:

this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)

然后创建绑定组,告诉 GPU 这个缓冲区在哪,把它绑到渲染通道:

this.bindGroup = this.device.createBindGroup({
  label: "bind group layout",
  layout: this.pipeline.getBindGroupLayout(0),
  entries: [{ binding: 0, resource: { buffer: this.cameraUniformBuffer } }],
})

在渲染通道里设置绑定组:

pass.setBindGroup(0, this.bindGroup);

最后在着色器里,定义个结构体,内存布局要跟缓冲区匹配:

struct CameraUniforms {
  view: mat4x4f,
  projection: mat4x4f,
  viewPos: vec3f,
  _padding: f32,
};

@group(0) @binding(0) var<uniform> camera: CameraUniforms;

现在着色器就能直接访问 camera.viewcamera.projection 了。在顶点着色器里,把每个顶点位置乘上这些矩阵:

@vertex
fn vs(@location(0) position: vec2<f32>) -> @builtin(position) vec4<f32> {
  return camera.projection * camera.view * vec4f(position, 0.0, 1.0);
}

渲染循环

要做出动画效果,得有个渲染循环,每帧都调用一次渲染函数。用 requestAnimationFrame 就行:

const render = () => {
  // 更新相机(响应鼠标输入)
  this.camera.update()
  
  // 更新 uniform 缓冲区
  this.device.queue.writeBuffer(this.cameraUniformBuffer, 0, this.cameraMatrixData)
  
  // 渲染
  // ...
  
  requestAnimationFrame(render)
}

为什么用 Uniform 缓冲区?

uniform 缓冲区是 WebGPU 的基础模式,用来从 CPU 往 GPU 传数据,比如光照参数、材质属性、变换矩阵。它是只读的,适合每帧更新一次的数据(像相机矩阵)。storage 缓冲区是可读写的,适合需要 GPU 计算的数据。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_7v85ohemb42ikeyb8wl4.webp


Engine v2: 渲染角色几何体

现在从硬编码的三角形转到真正的模型几何体。我们用预解析好的 PMX 模型数据,这是 MMD(MikuMikuDance)动漫角色的标准格式。MMD 在动漫风格角色建模里很常用,很多社区都在做《原神》《深空之眼》这些游戏的模型。解析器这里不讲了(什么格式都行,需要的话让 AI 生成解析器)。重点是理解两个数据结构:顶点和索引。

顶点数据结构

每个顶点包含三种数据,按顺序存在内存里(这叫交错顶点数据):

  • 位置:3D 空间里的 [x, y, z] 坐标
  • 法线:垂直于表面的 [nx, ny, nz] 方向(用来算光照)
  • UV 坐标[u, v] 纹理映射坐标(告诉纹理图片的哪部分要显示)

索引缓冲区

索引缓冲区指定哪些顶点组成三角形。不用复制顶点数据,用索引引用就行,能省很多内存。

比如一个正方形要 4 个顶点,但可以用 2 个三角形(6 个索引)表示,不用存 6 个重复顶点。

实现细节

engines/v2.ts 里,从模型数据创建顶点和索引缓冲区。看 initVertexBuffers 方法:

private initVertexBuffers() {
  const vertices = Float32Array.from(this.model.vertices)
  this.vertexBuffer = this.device.createBuffer({
    label: "model vertex buffer",
    size: vertices.byteLength,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  })
  this.device.queue.writeBuffer(this.vertexBuffer, 0, vertices.buffer)

  // 创建索引缓冲区
  const indices = Uint32Array.from(this.model.indices)
  this.indexBuffer = this.device.createBuffer({
    label: "model index buffer",
    size: indices.byteLength,
    usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
  })
  this.device.queue.writeBuffer(this.indexBuffer, 0, indices.buffer)
}

关键变化是用索引绘制,不用直接绘制。渲染通道调用 drawIndexed,指定索引缓冲区:

pass.setVertexBuffer(0, this.vertexBuffer)
pass.setIndexBuffer(this.indexBuffer, "uint32")
pass.drawIndexed(this.model.indices.length) // 使用索引绘制所有三角形

顶点布局定义

创建渲染管线时,要定义顶点布局,告诉 GPU 怎么解释缓冲区里的数据:

vertex: {
  module: shaderModule,
  entryPoint: "vs",
  buffers: [{
    arrayStride: 32, // 每个顶点 32 字节(3 个 float32 位置 + 3 个 float32 法线 + 2 个 float32 UV)
    attributes: [
      { shaderLocation: 0, offset: 0, format: "float32x3" },  // 位置
      { shaderLocation: 1, offset: 12, format: "float32x3" }, // 法线
      { shaderLocation: 2, offset: 24, format: "float32x2" }, // UV
    ],
  }],
}

结果就是角色的红色轮廓。没纹理(后面会加),只能看到原始几何体。但这是个重要里程碑——从 3 个硬编码顶点,到能渲染几千个三角形的复杂模型。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_41o3h5pvjemwfor3p5u5.webp


Engine v3: 材质和纹理

现在加纹理,给角色上色和细节。这里有两个概念:材质纹理

材质

材质把一组顶点(通过索引)连起来,指定画这些三角形时用哪些纹理和参数。在角色模型里,材质可以是脸、头发、衣服这些部分。每个材质包含:

  • 纹理索引(用哪个纹理)
  • 渲染参数(透明度、混合模式等)
  • 顶点范围(哪些三角形属于这个材质)

复杂角色模型里,不同部分(脸、头发、衣服)要用不同材质和纹理,所以得按材质分别渲染。

纹理

纹理就是存颜色数据的图片文件。在 WebGPU 里,得手动把图片数据上传到 GPU。每个顶点都有 UV 坐标(类似纹理的 x、y 坐标),用来映射到纹理的某个位置。片段着色器用这些坐标采样纹理,决定每个像素的颜色。

UV 坐标:想象一张地图,UV 坐标就像经纬度,告诉着色器"这个顶点对应纹理图片的哪个位置"。UV 范围通常是 0.0 到 1.0,(0,0) 是左上角,(1,1) 是右下角。

加载和创建纹理

engines/v3.ts 里,先加载纹理图片,创建 GPU 纹理。看 initTexture 方法:获取图片文件,创建 ImageBitmap,然后创建 GPUTexture 并上传图片数据:

const imageBitmap = await createImageBitmap(await response.blob())
const texture = this.device.createTexture({
  size: [imageBitmap.width, imageBitmap.height],
  format: "rgba8unorm",
  usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
})
this.device.queue.copyExternalImageToTexture({ source: imageBitmap }, { texture }, [
  imageBitmap.width,
  imageBitmap.height,
])

采样器

然后创建采样器,定义怎么采样纹理(过滤、包装等):

this.sampler = this.device.createSampler({
  magFilter: "linear",      // 放大时的过滤方式
  minFilter: "linear",      // 缩小时的过滤方式
  addressModeU: "repeat",   // U 方向的包装模式
  addressModeV: "repeat",   // V 方向的包装模式
})
  • 过滤模式linear 平滑插值,nearest 像素化
  • 包装模式repeat 重复纹理,clamp-to-edge 夹到边缘

在着色器里传 UV 坐标

要把 UV 坐标从顶点着色器传到片段着色器,定义个 VertexOutput 结构,把位置和 UV 打包:

struct VertexOutput {
  @builtin(position) position: vec4<f32>,
  @location(0) uv: vec2<f32>,
}

@vertex
fn vs(
  @location(0) position: vec3<f32>,
  @location(2) uv: vec2<f32>
) -> VertexOutput {
  var output: VertexOutput;
  output.position = camera.projection * camera.view * vec4f(position, 1.0);
  output.uv = uv;
  return output;
}

片段着色器接收 UV 坐标,用 textureSample 采样纹理:

@fragment
fn fs(input: VertexOutput) -> @location(0) vec4<f32> {
  return vec4<f32>(textureSample(texture, textureSampler, input.uv).rgb, 1.0);
}

绑定纹理到着色器

要把纹理绑到着色器,给每个材质创建个绑定组,包含纹理和采样器。作为第二个绑定组,放在相机 uniform 旁边:

for (const material of this.model.materials) {
  const textureIndex = material.diffuseTextureIndex
  const materialBindGroup = this.device.createBindGroup({
    layout: this.pipeline.getBindGroupLayout(1),
    entries: [
      { binding: 0, resource: this.textures[textureIndex].createView() },
      { binding: 1, resource: this.sampler },
    ],
  })
  this.materialBindGroups.push(materialBindGroup)
}

按材质渲染

最后按材质分别渲染。别对整个模型调一次 drawIndexed,要遍历材质,设置每个材质的绑定组,画它的三角形:

let firstIndex = 0
for (let i = 0; i < this.model.materials.length; i++) {
  const material = this.model.materials[i]
  if (material.vertexCount === 0) continue

  pass.setBindGroup(1, this.materialBindGroups[i])
  pass.drawIndexed(material.vertexCount, 1, firstIndex)
  firstIndex += material.vertexCount
}

结果就是完全贴图的角色了。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_vttc9ecp2u7w122yj6md.webp

深度测试

你可能会发现角色看起来透明,或者能看到背面。这是因为没开深度测试,GPU 按提交顺序画三角形——远处的可能画在近处的上面。

修复很简单,三步:创建深度纹理,加到渲染通道,配置管线。不用改着色器:

// 创建深度纹理
this.depthTexture = this.device.createTexture({
  size: [width, height],
  format: "depth24plus",
  usage: GPUTextureUsage.RENDER_ATTACHMENT,
})

// 添加到渲染通道
depthStencilAttachment: {
  view: this.depthTexture.createView(),
  depthClearValue: 1.0,      // 1.0 表示最远
  depthLoadOp: "clear",      // 每帧清除深度缓冲区
  depthStoreOp: "store",     // 存储深度值用于下一帧
}

// 添加到管线
depthStencil: {
  depthWriteEnabled: true,   // 允许写入深度值
  depthCompare: "less",      // 只渲染更近的片段
  format: "depth24plus",
}

完整实现在 engines/v3_2.ts。有了材质、纹理和深度测试,静态渲染管线就完整了。角色完全贴图,从任何角度看都是实心的。

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_zw1fja133f3s5w340393.webp


Engine v4: 骨骼和蒙皮

在 WebGPU 里,得理解骨骼系统怎么工作。高级框架会自动处理骨骼动画,但这里得手动实现。

骨骼和层次结构

骨骼是层次结构里的变换。想象个木偶:关节(肩膀、肘部、手腕)就是骨骼,用线(层次关系)连起来。每个骨骼都有个父骨骼(除了根骨骼),移动父骨骼,所有子骨骼都会跟着动。MMD 模型里,典型的臂链是这样的:

センター (center) → 上半身 (upper_body) → 右肩 (shoulder_R) → 右腕 (arm_R) → 右ひじ (elbow_R) → 右手首 (wrist_R) → 手指关节

旋转上半身时,整个上半身——肩膀、手臂、肘部、手腕、手指——都会跟着转。这种级联效果是因为每个骨骼的变换是相对于父骨骼的。

就像 DOM 树,父元素动了,子元素也跟着动。骨骼系统就是 3D 空间里的"DOM 树"。

骨骼变换的数学原理

每个骨骼都有个局部变换矩阵,表示相对父骨骼的旋转、缩放、平移。要算骨骼在世界空间的最终位置,从根骨骼往下遍历,把每个骨骼的局部变换乘上父骨骼的世界变换:

worldMatrix[bone] = worldMatrix[parent] × localMatrix[bone]

这样子骨骼就会跟着父骨骼移动。

蒙皮:将骨骼连接到顶点

蒙皮就是骨骼怎么变形网格。这是骨骼动画的核心概念。

简单说:想象个木偶,皮肤(网格)要跟着关节(骨骼)动。但皮肤上的每个点(顶点)可能同时受多个关节影响。比如肩膀附近的顶点主要受肩膀骨骼影响,但也稍微受上臂骨骼影响,这样动起来过渡更自然。

每个顶点存最多 4 个骨骼索引和 4 个权重,权重总和是 1.0。骨骼移动时,顶点的最终位置是加权混合:

// 顶点数据
joints:  [15, 16, 0, 0]    // 骨骼索引:这个顶点受骨骼 15 和 16 影响
weights: [0.7, 0.3, 0, 0]  // 权重:70% 来自骨骼 15,30% 来自骨骼 16

// 最终位置 = 每个骨骼变换的加权和
finalPosition = (skinMatrix[15] * position) * 0.7 
              + (skinMatrix[16] * position) * 0.3

就像 CSS 的 transform-origin,但更复杂。一个顶点可以同时有多个"原点"(骨骼),最终位置是这些原点的加权平均。

每个骨骼的 skinMatrix 结合了当前姿态和绑定姿态(bind pose),让骨骼旋转时能平滑变形。绑定姿态是模型的初始姿态(通常是 T-pose 或 A-pose),用来算顶点相对骨骼的原始位置。

绑定姿态(Bind Pose)和蒙皮矩阵

绑定姿态是模型在 T-pose 或 A-pose 时的原始姿态。每个骨骼都有个逆绑定矩阵(inverse bind matrix),用来把顶点从世界空间转回骨骼的局部空间。蒙皮矩阵这么算:

skinMatrix = worldMatrix × inverseBindMatrix

这样顶点会跟着骨骼移动,同时保持相对骨骼的正确位置。

CPU 端的骨骼控制

骨骼在 CPU 上。动画、物理、用户输入都在这里更新骨骼旋转。旋转骨骼时,引擎重新算层次结构(父到子变换),把结果上传到 GPU:

// 你的游戏代码:旋转颈部骨骼
engine.rotateBone("首", rotation)

// 内部,这触发:
// 1. evaluatePose() - 从层次结构重新计算所有世界矩阵
// 2. 上传世界矩阵到 GPU
// 3. 计算通道 - 计算蒙皮矩阵
// 4. 下一次渲染使用更新的蒙皮

计算着色器:并行矩阵计算

几百个骨骼、几千个顶点,在 CPU 上算蒙皮矩阵太慢了。这就是计算着色器的用武之地——这也是 WebGPU 相对 WebGL 的关键优势。计算着色器在 GPU 上做大规模并行计算,很适合矩阵运算。

把骨骼矩阵上传到 storage 缓冲区,然后调度计算着色器并行算所有蒙皮矩阵。471 个骨骼的模型,就是 471 个矩阵乘法在 GPU 上同时跑:

@group(0) @binding(1) var<storage, read> worldMatrices: array<mat4x4f>;
@group(0) @binding(2) var<storage, read> inverseBindMatrices: array<mat4x4f>;
@group(0) @binding(3) var<storage, read_write> skinMatrices: array<mat4x4f>;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) globalId: vec3<u32>) {
  let boneIndex = globalId.x;
  if (boneIndex >= boneCount.count) { return; }
  
  skinMatrices[boneIndex] = worldMatrices[boneIndex] * inverseBindMatrices[boneIndex];
}

Storage 缓冲区 vs Uniform 缓冲区

  • Uniform 缓冲区:只读,大小有限(通常 64KB),适合每帧更新一次的小数据(像相机矩阵)
  • Storage 缓冲区:可读写,大小几乎无限制,适合需要 GPU 计算的大数据(像骨骼矩阵数组)

完整的渲染流程

每帧的完整流程(完整实现在 engines/v4.ts):

  1. CPU:动画或用户输入更新骨骼旋转
  2. CPUevaluatePose() 遍历层次结构,计算世界矩阵
  3. CPU → GPU:上传世界矩阵到 storage 缓冲区
  4. GPU 计算通道:并行计算所有骨骼的 skinMatrix = world × inverseBind
  5. GPU 渲染通道:顶点着色器读蒙皮矩阵,按每个顶点的骨骼权重混合顶点

顶点着色器做最终的蒙皮计算:

@group(0) @binding(1) var<storage, read> skinMats: array<mat4x4f>;

@vertex
fn vs(
  @location(0) position: vec3<f32>,
  @location(3) joints: vec4<u32>,
  @location(4) weights: vec4<f32>
) -> VertexOutput {
  // 根据骨骼影响混合位置
  var skinnedPos = vec4f(0.0);
  for (var i = 0u; i < 4u; i++) {
    skinnedPos += (skinMats[joints[i]] * vec4f(position, 1.0)) * weights[i];
  }
  
  output.position = camera.projection * camera.view * skinnedPos;
}

计算通道设置

要用计算着色器,创建个计算管线并调度它:

// 创建计算管线
const computePipeline = this.device.createComputePipeline({
  layout: "auto",
  compute: {
    module: computeShaderModule,
    entryPoint: "main",
  },
})

// 在计算通道中调度
const computePass = encoder.beginComputePass()
computePass.setPipeline(computePipeline)
computePass.setBindGroup(0, computeBindGroup)
computePass.dispatchWorkgroups(Math.ceil(boneCount / 64)) // 64 是工作组大小
computePass.end()

https___dev-to-uploads.s3.amazonaws.com_uploads_articles_shb5xum7i07w1jicru4t.webp


总结

现在你已经搭好了一个完整的 WebGPU 渲染管线——从简单三角形到完全贴图、骨骼动画的角色。你理解了核心组件(缓冲区、绑定组、管线、渲染通道),它们怎么连起来(CPU 到 GPU 的数据流、着色器接口),以及为什么这样设计(uniform vs storage 缓冲区、计算着色器做并行计算)。

关键点

  1. GPU 是独立的计算单元:需要明确的数据传输和指令
  2. 缓冲区是数据传输的基础:vertex、index、uniform、storage 缓冲区各有各的用途
  3. 着色器定义处理逻辑:vertex、fragment、compute 着色器在管线的不同阶段跑
  4. 绑定组连接资源:把缓冲区、纹理、采样器组织在一起
  5. 计算着色器做并行:利用 GPU 的并行计算能力

下一步

这个教程主要讲 WebGPU 的基础。物理模拟、逆运动学、动态光照、后处理这些高级功能都建立在这些概念上——它们是应用级功能,不是新的 WebGPU 原语。可以在 Reze Engine 源码里探索这些,它把这里学的东西扩展成了一个完整的动漫角色渲染器。


本教程是 Reze Engine 项目的一部分。完整源代码可在 GitHub 上找到。

从零打造 AI 全栈应用(二):前端路由工程化设计与性能优化实践

在上一篇文章中
👉 《从零打造 AI 全栈应用(一):深度解析 Shadcn UI + Vite + NestJS 的工程化最佳实践》
我们从整体视角拆解了 AI 全栈项目的技术选型与工程结构,重点放在 UI 体系、构建工具与后端框架的协同设计 上。

但真正开始落地一个 AI 应用时,你会很快发现一个问题:

页面越来越多,能力越来越复杂,路由如果一开始没设计好,后期几乎一定要推倒重来。

因此,本文将单独聚焦一个经常被低估、却极其关键的模块 ——
前端路由的工程化设计


一、为什么 AI 应用对“路由设计”要求更高

与普通信息型网站不同,AI 应用通常具备以下特征:

  • 页面模块多(Chat / 订单 / 订阅 / 用户中心 / 历史记录)
  • 页面体积大(富交互、状态复杂)
  • 强登录态与权限边界
  • 高频异步加载(模型调用、流式返回)

如果路由层只是“能跳就行”,常见后果包括:

  • 首屏 JS 包过大,加载慢
  • 每次加页面都要改一堆路由逻辑
  • 登录态判断散落在各个页面
  • 布局代码重复、难以维护

👉 结论很明确:路由必须从一开始就工程化设计。


二、路由级懒加载:AI 应用的性能生命线

1. 问题本质:不是所有用户都会访问所有页面

在 AI 应用中:

  • 未登录用户只会访问登录页
  • 大部分用户高频使用的只有 Chat
  • 订单、个人中心访问频率远低于首页

如果一次性加载所有页面代码,本质是在 浪费用户带宽和首屏时间


2. React 中的路由级懒加载方案

React 官方提供了两块基础能力:

  • React.lazy:定义异步组件
  • Suspense:处理异步加载期间的 UI 状态

页面组件的声明方式如下:

import { lazy } from 'react';

const Home = lazy(() => import('@/pages/Home'));
const Mine = lazy(() => import('@/pages/Mine'));
const Login = lazy(() => import('@/pages/Login'));
const Chat = lazy(() => import('@/pages/Chat'));
const Order = lazy(() => import('@/pages/Order'));

这段代码背后做了三件非常重要的事:

  1. 每个页面会被打包成独立的 chunk
  2. 只有在路由命中时才会下载对应代码
  3. 极大降低首屏资源体积

📌 面试高频点
React.lazy 返回的是一个异步组件,必须运行在 Suspense 之下。


三、Suspense + Loading:把“等待”当成一等公民

<Suspense fallback={<Loading />}>
  <Routes>
    {/* 路由表 */}
  </Routes>
</Suspense>

1. Suspense 在这里到底解决什么问题

当用户首次进入某个路由时:

  • 对应页面 chunk 尚未下载完成
  • React 会暂停渲染该组件
  • Suspense 统一兜底展示 fallback

如果没有 Suspense:

  • ❌ 页面直接报错
  • ❌ 用户看到空白页面

2. 为什么 Loading 一定要抽成组件

在 AI 应用中,“加载中”并不是一个可忽略的瞬间状态:

  • 模型响应有真实等待时间
  • 页面切换频繁
  • 用户对流畅度非常敏感

将 Loading 抽成独立组件,可以:

  • 统一加载态视觉风格
  • 后期无痛升级为骨架屏
  • 区分首屏 Loading / 路由切换 Loading

👉 这不是 UI 细节,而是产品体验的一部分。


四、Layout Route:决定项目能走多远的设计

<Route path="/" element={<MainLayout />}>
  <Route path="" element={<Home />} />
  <Route path="mine" element={<Mine />} />
  <Route path="chat" element={<Chat />} />
  <Route path="order" element={<Order />} />
</Route>

这层结构是整个路由设计中最关键的一步


1. Layout Route 的核心思想

在 SPA 中,有两类组件:

  • 结构组件(Layout)
  • 内容组件(Page)

MainLayout 通常包含:

  • Header / 导航栏
  • 公共容器
  • <Outlet /> 子路由出口
function MainLayout() {
  return (
    <>
      <Header />
      <Outlet />
      <TabBar />
    </>
  );
}

当访问 /chat 时:

  • React Router 先渲染 MainLayout
  • 再将 Chat 渲染到 Outlet

👉 页面在变,结构不变。


2. 为什么 AI 应用特别适合这种设计

  • Chat、订单、个人中心共享布局
  • 新增模块只需添加子路由
  • 公共逻辑集中,维护成本低

这是一种 长期友好型架构


五、路由守卫:登录态与访问边界控制

AI 应用几乎一定存在明确的访问边界:

  • 未登录用户只能访问登录页
  • 登录后才能使用核心能力

但 React Router 并没有 Vue 那样的“路由守卫 API”。


1. React Router 的官方设计哲学

一切皆组件

因此,所谓“路由守卫”,本质就是 条件渲染


2. AuthRoute:最清晰、可维护的实现方式

import { Navigate } from 'react-router-dom';

function AuthRoute({ children }: { children: JSX.Element }) {
  const { isLogin } = useUserStore();

  if (!isLogin) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

使用方式:

<Route
  path="order"
  element={
    <AuthRoute>
      <Order />
    </AuthRoute>
  }
/>

这种设计的优势非常明显:

  • 权限逻辑集中
  • 路由配置依然清晰
  • 与 Zustand / Redux 等状态管理天然契合

📌 面试一句话总结
React Router 的路由守卫,本质是通过组件包裹实现的条件控制。


六、RouterConfig:它是基础设施,不是业务代码

export default function RouterConfig({ children }) {
  return (
    <Router>
      <Suspense fallback={<Loading />}>
        <Routes>{/* 路由表 */}</Routes>
      </Suspense>
      {children}
    </Router>
  );
}

这个结构体现了一种非常成熟的工程意识:

  • Router 是最外层基础设施
  • Suspense 统一管理异步边界
  • children 为全局能力预留扩展点(如 Toast、全局弹窗、埋点)

👉 这已经不是“会用路由”,而是在“设计路由系统”。


七、总结:一个合格的 AI 应用路由层应该具备什么

  • 路由级懒加载(性能)
  • Suspense 统一兜底(稳定性)
  • Loading 抽象(体验)
  • Layout Route(结构清晰)
  • 路由守卫(业务边界)

如果你能把这套设计讲清楚:

面试官听到的不是 API,而是你的架构能力。

React学习:状态管理的中央银行——Zustand

前言:关于“状态管理”的那点破事

兄弟们,咱们做前端开发的,大概率都经历过被 Redux 支配的恐惧。

那是怎样一种体验?想要修改一个 count,你得先写 Action Type,再写 Action Creator,然后跑到 Reducer 里写 switch-case,最后还得在组件里用 connect 或者 useDispatch 连半天。写完这一套,头发都掉两根了,回头一看,我特么只是想让数字 +1 啊!

后来有了 Context API + useReducer,虽然原生了,但性能优化又成了噩梦(Context 一变,全家重渲染)。

这时候,Zustand 骑着一只可爱的小熊来了。

Zustand(德语“状态”)主打一个极简、轻量、Hooks 风格。它不需要你在外面包一层 Provider,也不需要繁琐的模板代码。今天,咱们就结合一个全栈项目(React + TS + Node)的实战场景,从零开始,把 Zustand 玩得明明白白。


一、 起步:为什么是 Zustand?

在我们的全栈规划中,前端部分是 React + TypeScript。如果把组件比作“切图仔”,那状态管理就是“中央银行”。

Zustand 的核心优势就在于:

  1. 极简主义:API 极少,心智负担几乎为零。
  2. Hooks 优先:完美契合 React 16.8+ 的开发范式。
  3. 灵活的中央集权:支持多 Store,也可以单 Store,随你喜欢。
  4. 不需要 Context Provider:告别组件树顶层那一堆乱七八糟的嵌套。

准备好了吗?咱们直接开整。

环境准备

假设你已经建好了一个 Vite + React + TS 的项目:

npm install zustand

就这一行,完事。


二、 Level 1:从计数器开始(基础用法)

咱们先拿最经典的 Counter 练练手。在 src/store 下新建 counter.ts。

1. 定义状态的“规矩” (Interface)

既然用了 TypeScript,咱们就得讲究点,别满屏 any 乱飞。先定义好咱们的 State 长啥样。

// src/store/counter.ts
import { create } from "zustand";

// 1. 定义接口:状态里有什么,怎么改状态
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

2. 创建仓库 (Create Store)

Zustand 的 create 函数是核心。它接收一个回调,回调里有两个关键参数:set 和 get。

  • set: 用于更新状态(你可以把它理解为全局的 setState)。
  • get: 用于获取当前状态(偶尔在 action 里需要读取其他状态时用)。
// 2. 创建 Store
export const useCounterStore = create<CounterState>()((set) => ({
  count: 0, // 初始值
  
  // 动作 Action:直接写函数
  increment: () => set((state) => ({ count: state.count + 1 })),
  
  decrement: () => set((state) => ({ count: state.count - 1 })),
  
  // 直接赋值这种写法也行,set 会自动合并第一层属性
  reset: () => set({ count: 0 }),
}));

知识点
Zustand 的 set 默认是合并更新(Merge),不是替换。也就是说,如果你状态里有 a 和 b,你 set({ a: 1 }),b 不会丢。


三、 Level 2:搞定复杂对象(Todo List 实战)

计数器太简单了,咱们来点真实的。Todo 表结构如下:

1. 类型定义

先在 src/types/index.ts 里把类型锁死:

// src/types/index.ts
export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

2. Todo Store 实现

在 src/store/todo.ts 里,我们要处理数组的操作:增、删、改。这里展示了如何在 Zustand 里优雅地操作不可变数据。

// src/store/todo.ts
import { create } from "zustand";
import type { Todo } from "../types";

export interface TodoState {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  removeTodo: (id: number) => void;
}

export const useTodoStore = create<TodoState>()((set) => ({
  todos: [],

  // 增:展开旧数组,追加新对象
  addTodo: (text: string) =>
    set((state) => ({
      todos: [
        ...state.todos,
        {
          id: +new Date(), // 简单生成个 ID,实际项目请用 uuid 或后端 ID
          text,
          completed: false,
        },
      ],
    })),

  // 改:Map 遍历,找到目标取反,其他的保持原样
  toggleTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  // 删:Filter 过滤
  removeTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

看看这代码,是不是极其清爽?逻辑和数据在一起,没有 Redux 那种文件跳来跳去的感觉。

3. 在组件中使用

来到 App.tsx,咱们看看怎么用。

// src/App.tsx
import { useState } from "react";
import { useTodoStore } from "./store/todo";

function App() {
  // 就像用 useState 一样简单,直接解构
  const { todos, addTodo, toggleTodo, removeTodo } = useTodoStore();
  const [inputValue, setInputValue] = useState("");

  const handleAdd = () => {
    if (!inputValue.trim()) return;
    addTodo(inputValue);
    setInputValue("");
  };

  return (
    <section>
      <h2>Todos 数量: {todos.length}</h2>
      {/* 输入框区域 */}
      <div>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleAdd()}
        />
        <button onClick={handleAdd}>Add</button>
      </div>

      {/* 列表区域 */}
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
              {todo.text}
            </span>
            <button onClick={() => removeTodo(todo.id)}>Del</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

export default App;

四、 Level 3:数据持久化(Middleware 中间件)

咱们现在的 Todo List 有个致命弱点:一刷新页面,数据全没了。用户辛辛苦苦写的待办事项瞬间归零,这在生产环境是要被祭天的。

以前咱们得手动 localStorage.setItem,现在 Zustand 自带中间件—— persist。

我们改造一下 todo.ts:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware"; // 引入中间件
import type { Todo } from "../types";

// ... TodoState 接口保持不变 ...

export const useTodoStore = create<TodoState>()(
  // 使用 persist 包裹你的核心逻辑
  persist(
    (set, get) => ({
      todos: [],
      addTodo: (text) => set(state => ({ ... })), 
      toggleTodo: (id) => set(state => ({ ... })),
      removeTodo: (id) => set(state => ({ ... })),
    }),
    {
      name: "todos-storage", // 必填:localStorage 里的 key 叫什么
      // storage: createJSONStorage(() => sessionStorage), // 选填:默认是 localStorage,你也可以换成 sessionStorage
    }
  )
);

见证奇迹的时刻:当你写完这段代码,打开浏览器的 Application -> Local Storage,你会发现多了一个 todos-storage。刷新页面,数据依然健在!Zustand 自动帮你处理了序列化和反序列化。


五、 Level 4:异步与用户信息(模拟登录)

在全栈项目中,最常见的场景就是:登录
这是一个典型的异步操作流程:

  1. 调用后端 API。
  2. 等待响应。
  3. 成功 -> 存 User 信息,改登录状态。
  4. 失败 -> 报错。
// src/store/user.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { User } from "../types";

interface UserState {
  isLoggin: boolean;
  userInfo: User | null;
  // 这里的 login 支持接收参数
  login: (params: { username: string; password: string }) => Promise<void>;
  logout: () => void;
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      isLoggin: false,
      userInfo: null,

      // 异步 Action:直接写 async/await,毫无黑魔法
      login: async ({ username, password }) => {
        // 模拟 API 请求
        // const res = await axios.post('/api/login', { username, password });
        
        console.log("正在登录...", username);
        // 模拟延迟
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        // 模拟后端返回的用户数据
        const mockUser: User = { id: 1, username: username, avatar: "https://i.pravatar.cc/150" };

        // 更新状态
        set({ isLoggin: true, userInfo: mockUser });
      },

      logout: () => set({ isLoggin: false, userInfo: null }),
    }),
    { name: "user-session" }
  )
);

六、 进阶:性能优化

在 App.tsx 中,我们用了这种写法:

// 写法 A (全量订阅)
const { todos, addTodo } = useTodoStore();

这有个小问题:如果 Store 里还有个无关的状态(比如 filterType)变了,虽然 todos 没变,但组件可能会因为 Hook 的返回值变了而重新渲染(Zustand 默认会进行浅比较,通常问题不大,但有极致优化空间)。

专家级写法(Selector 模式)

如果你想极致优化性能,只订阅你需要的那个切片:

// 写法 B (精确订阅)
const todos = useTodoStore((state) => state.todos);
const addTodo = useTodoStore((state) => state.addTodo);

这样,只有当 state.todos 发生变化时,组件才会重渲染。对于大型列表或复杂页面,这是减少 Render 次数的利器。

此外,Zustand 甚至可以在 React 组件外部使用!

// 在非组件文件中使用(例如 axios 拦截器)
import { useUserStore } from "./store/user";

axios.interceptors.request.use((config) => {
  // 直接读取状态,不需要 hooks 规则
  const token = useUserStore.getState().userInfo?.token;
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

总结

Zustand 就像一把瑞士军刀,没有多余的装饰,拿起来就能干活。

  • :包体积极小。
  • :基于 Flux 模型,性能强悍。
  • :支持中间件、DevTools、TS 类型推断。

兄弟们,别再犹豫了。如果你的项目还没上 Redux 那些重型武器,或者你正在从 Vue 转 React 寻找那份“可变状态”的亲切感,Zustand 绝对是你的不二之选

❌