普通视图

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

深入理解 INP:从原理到实战的前端交互性能优化

2026年2月11日 14:20

背景

最近有做一些INP优化相关,简单记录一下。

INP高的重灾区就是手机端, 手机 CPU 弱、线程紧张,INP 高基本都出在移动端。

什么是 INP?

INP(Interaction to Next Paint,交互到下一次绘制) 是 Google Core Web Vitals 中评估网页交互响应性的核心指标。自 2024 年 3 月起,INP 正式取代了旧的 FID(First Input Delay),成为衡量用户交互体验的主要标准。

优化INP有什么用?

  • 📱 体验层面:解决移动端点击卡顿、无响应,让交互秒反馈,大幅提升流畅度。

  • 🔍 SEO 层面:INP 是 Google Core Web Vitals 核心指标,达标有利于搜索排名与流量。

  • 💰 业务层面:交互更流畅,用户流失更少,留存、转化率更高。

  • 🛠 技术层面:拆分长任务、优化主线程,让项目整体架构更轻、更快、更易维护。

INP vs FID:为什么要换?

对比维度 FID (旧指标) INP (新指标)
测量范围 仅第一次交互 全生命周期所有交互
测量内容 仅输入延迟 输入 + 处理 + 渲染 全链路
真实性 片面 更全面、更真实
优化指导 只关注首次交互 关注所有交互场景

简单来说:FID 只是"开胃菜",INP 才是"全席宴"——它能真正反映用户在使用过程中的完整体验。


INP 测量的是什么?

INP 测量的是:用户进行一次有效交互(点击、点按、键盘输入)后,到浏览器完成下一次视觉更新(paint)所花费的总时间。

时序图解

用户交互(click/tap/keypress)
        │
        ▼
   ┌─────────────┐    ┌─────────────┐    ┌─────────────────────────┐
   │  输入延迟   │ → │  事件处理   │ → │ 样式计算 + 布局 + 绘制  │ → 下一帧 paint
   └─────────────┘    └─────────────┘    └─────────────────────────┘
   ◀──────────────────── INP 完整耗时 ────────────────────────────▶


📌 关键点:INP 的最终值 = 页面生命周期中所有有效交互里最慢的那一次(取第 75 百分位)

评分标准(2025–2026)

根据 Google 官方标准,INP 以第 75 百分位(P75)进行评估:

等级 阈值 含义
🟢 良好 (Good) ≤ 200 ms 响应迅速,用户体验优秀
🟡 需改进 (Needs Improvement) 200–500 ms 有明显延迟感,需要优化
🔴 较差 (Poor) > 500 ms 严重卡顿,体验糟糕

优化目标:确保 75% 以上的用户交互响应时间控制在 200 ms 以内

1770779737686_00af473a3546431c8782fc39930c2adb.png

为什么 INP 会变差?

核心原因

主线程上存在长任务(Long Task > 50 ms),阻塞了浏览器处理后续交互和渲染。

典型场景

用户点击按钮 
    → setLoading(true) 
    → 立即执行 200–500 ms 的重计算/网络请求/复杂 DOM 操作 
    → 用户迟迟看不到 loading 状态
    → 感觉卡顿、无响应


问题在于:setLoading(true) 虽然调用了,但由于主线程被长任务占用,浏览器根本没机会渲染这个状态变化!

image.png

三个阶段的问题分布

根据实际项目经验,三个阶段的问题分布大致如下:

  • 输入延迟:30% - 通常是第三方脚本、初始化任务

  • 事件处理:50% - 最常见的问题,业务逻辑复杂

  • 呈现延迟:20% - DOM 操作、布局计算


INP 的三个阶段详解

INP 的测量范围涵盖三个关键阶段,每个阶段都可能成为性能瓶颈。深入理解这三个阶段,是优化 INP 的基础。

image.png

阶段一:输入延迟(Input Delay)

定义:从用户触发交互到事件处理器开始执行之间的等待时间。

原因:主线程被其他任务占用(长任务 > 50ms、同步渲染、第三方脚本、垃圾回收),浏览器无法立即响应交互。

案例比如如下,输入延迟348ms,脚本加载阻塞了交互 导致用户交互卡顿

f41ca817c123575b3394dbeab12da896.png

优化策略

  • 拆分长任务:使用 scheduler.yield() 或 scheduler.postTask 让出主线程

  • 延迟非关键任务:使用 requestIdleCallback 在空闲时执行

  • 优化第三方脚本:异步加载、延迟执行


阶段二:事件处理(Processing Time)

定义:事件处理器从开始执行到执行完成所花费的时间。

原因:处理器内部执行耗时操作(复杂计算、大量 DOM 操作、同步网络请求、强制同步布局),阻塞主线程。

实际案例

// ❌ 问题:用户点击更换背景,但画布操作耗时,看不到即时反馈
// 来源:src/core/FTCanvasRenderer.ts - updateBackground
async updateBackground({ bgUrl, bgColor, size, callback }) {
  FTBgremoveStore.changeBackgroundLoading = true;  // 状态已更新,但浏览器还没渲染
  
  // 同步执行耗时操作,阻塞主线程
  const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl);  // 100-200ms
  bg.setSource(blobUrl, "imageUrl", () => {
    this.blurBackground(currentPageData.blurValue || 0);  // 50-100ms 模糊处理
    fabricCanvas.requestRenderAll();  // 触发重绘
    FTBgremoveStore.changeBackgroundLoading = false;
  });
}

// ✅ 优化:先让浏览器渲染反馈,再执行耗时操作
import { nextTick } from 'src/utils';

async updateBackground({ bgUrl, bgColor, size, callback }) {
  FTBgremoveStore.changeBackgroundLoading = true;
  
  // 让出主线程,确保浏览器先渲染 loading 状态
  await nextTick();
  
  // 现在执行耗时操作
  const blobUrl = await FTBlobCacheManager.getInstance().addCache(bgUrl);
  bg.setSource(blobUrl, "imageUrl", async () => {
    await nextTick();  // 再次让出,确保模糊处理不阻塞
    this.blurBackground(currentPageData.blurValue || 0);
    fabricCanvas.requestRenderAll();
    FTBgremoveStore.changeBackgroundLoading = false;
  });
}


优化策略

  • 先反馈再处理:使用 nextTick() 让浏览器先渲染状态变化

  • 拆分长任务:将耗时操作拆成多个小任务

  • 使用 Web Worker:将计算密集型任务移到 Worker 线程


阶段三:呈现延迟(Presentation Delay)

定义:从事件处理器执行完成,到浏览器完成下一次 paint(绘制)之间的时间。

原因:浏览器渲染管道(样式计算 → 布局 → 绘制 → 合成)耗时过长,常见问题包括强制同步布局、大量重排、复杂 CSS 选择器。

优化策略

  • 避免强制同步布局:先批量读取布局属性,再批量写入 DOM

  • 减少重排重绘

  • 优化 CSS 选择器:避免深层嵌套,使用类选择器


INP优化核心思路

🎯 核心原则让用户交互后的第一个 paint 尽快发生,把重的、非必须立即执行的工作延迟或拆分

这样做能让你推迟执行的任务不算在INP时间计算内

❌ 错误示范:阻塞渲染

// 来源:src/store/FTBgremoveStore.tsx - handleLoadSimplifyCanvas
async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
  this.cutoutLoading = true;  // 调用了,但...
  
  // 立即执行耗时操作,阻塞主线程
  const img = await loadImageByUrl(imgUrl);  // 100ms 加载图片
  await FTSimpleCanvasRenderInstance.uploadTop(imgUrl);  // 80ms 上传到画布
  await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor);  // 50ms 设置背景
  this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... });  // 同步更新状态
  
  this.cutoutLoading = false;
}


✅ 正确示范:先反馈,再干活

import { nextTick } from 'src/utils';

async handleLoadSimplifyCanvas(imgUrl, id, closeLoading = true) {
  // 1. 同步更新状态
  this.cutoutLoading = true;

  // 2. 关键:让出主线程,给浏览器渲染机会
  await nextTick();  // 浏览器有机会渲染 loading 状态

  // 3. 现在才开始执行重任务
  const img = await loadImageByUrl(imgUrl);
  await FTSimpleCanvasRenderInstance.uploadTop(imgUrl);
  await FTSimpleCanvasRenderInstance.uploadBack(page.backgroundColor);
  
  // 再次让出,确保状态更新能及时渲染
  await nextTick();
  this.setCurrentPage({ cutOriginUrl: imgUrl, cropImage: image, ... });
  this.cutoutLoading = false;
}


拆分和调度任务、让事件能快速响应。

image.png

工具函数:nextTick 实现

项目中已经实现了优化的 nextTick 函数(src/utils/index.ts),通过让出主线程控制权,确保浏览器有机会完成渲染和响应用户交互:

/**
 * nextTick - 将任务推迟执行,优化 INP (Interaction to Next Paint)
 *
 * 改进点:
 * 1. 优先使用 scheduler.postTask (如果可用) 来更好地控制任务优先级
 * 2. 使用 MessageChannel 确保任务在下一个事件循环执行,不阻塞渲染
 * 3. 双重 requestAnimationFrame 作为降级方案,确保 DOM 更新后执行
 *
 * @param callBack 要执行的回调函数
 * @returns Promise<void>
 */
export function nextTick(callBack?: () => void) {
  return new Promise<void>((resolve) => {
    // 优先使用 scheduler.postTask (Chrome 94+, 更好的任务调度)
    if (typeof (window as any).scheduler !== 'undefined' && (window as any).scheduler.postTask) {
      (window as any).scheduler.postTask(() => {
        callBack?.();
        resolve();
      }, { priority: 'user-blocking' });
      return;
    }

    // 使用 MessageChannel 确保在下一个事件循环执行,不阻塞渲染
    // 这对于 INP 优化很重要,因为它让浏览器有机会处理用户交互
    if (typeof MessageChannel !== 'undefined') {
      const channel = new MessageChannel();
      channel.port1.onmessage = () => {
        callBack?.();
        resolve();
      };
      channel.port2.postMessage(null);
      return;
    }

    // 降级方案:双重 requestAnimationFrame
    // 第一个 rAF 确保在浏览器重绘之前,第二个 rAF 确保在重绘之后
    // 这样可以确保 DOM 更新已经完成
    if (requestAnimationFrame) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => {
          callBack?.();
          resolve();
        });
      });
      return;
    }

    // 最后的降级方案
    if (setImmediate) {
      setImmediate(() => {
        callBack?.();
        resolve();
      });
    } else {
      setTimeout(() => {
        callBack?.();
        resolve();
      }, 0);
    }
  });
}


使用示例

使用 nextTick() 主动让步。

// 使用项目中的 nextTick 函数
import { nextTick } from 'src/utils';

async function processHeavyCanvas() {
  doLightWork();
  
  // 让浏览器有机会处理待渲染的帧和用户交互
  await nextTick();
  // 这样doHeavyWork会被拆分成单独任务延迟执行
  doHeavyWork();
}


为什么选择这些 API?

API 优点 兼容性
scheduler.postTask 支持优先级控制,专为任务调度设计 Chrome 94+
MessageChannel 宏任务,确保在渲染后执行 广泛支持
双重 rAF 确保在下一帧渲染完成后执行 广泛支持
setTimeout(0) 最终兜底方案 全平台

实战检测与调试

使用 Chrome DevTools 分析 INP

cpu 4 - 6倍降速,模拟低性能设备。 对比本地和线上

image.png

点点功能,观察Performace面板实时的INP

image.png

每次交互的实时INP都会记录在这里。 多点点自己的项目,顺着INP变高的那一步操作找问题即可。

使用 Web Vitals 库监控

import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', metric.value);
  console.log('Entries:', metric.entries);
  
  // 上报到分析平台
  if (metric.value > 200) {
    analytics.track('slow_interaction', {
      value: metric.value,
      entries: metric.entries,
      target: metric.entries[0]?.target,
    });
  }
});


分析线上用户真实指标

查看线上真实用户指标

pagespeed.web.dev/

Chrome 用户体验报告(crux)

cruxvis.withgoogle.com/


总结

核心要点

  1. INP 测量三个阶段:输入延迟、事件处理、呈现延迟

  2. 优化目标:确保 75% 的交互在 200ms 内完成

  3. 核心策略:先反馈,再处理;拆分长任务;让出主线程

  4. 工具支持:使用 nextTickscheduler.yield()、Web Worker

  5. 真实用户INP观察: crux查看用户真实INP变化,持续优化。

INP 优化的本质是:让用户"感觉"交互是即时响应的,哪怕后台还在默默干活。

参考资料

React Hooks 在 Table Column Render 回调中的使用陷阱

作者 刀疤
2026年2月11日 14:01

📋 问题描述

在使用 Ant Design Table 组件时,尝试将包含 useState Hook 的代码从子组件内联到 Columnrender 回调中,导致页面黑屏。

场景还原

原始实现(正常工作):

// editButton.tsx - 独立子组件
import { Button } from "antd";
import { EditModal } from "./edit";
import { useState } from "react";

export default function EditButton({ data }) {
  const [editOpen, setEditOpen] = useState(false);

  const onEdit = () => {
    setEditOpen(true);
  };

  return (
    <>
      <Button type="link" onClick={onEdit}>
        编辑
      </Button>
      <EditModal isModalOpen={editOpen} setIsModalOpen={setEditOpen} />
    </>
  );
}
// index.tsx - 父组件中使用
<Column
  title="操作"
  dataIndex="action"
  key="action"
  render={(_: any, record: any) => {
    return <EditButton data={record} />; // ✅ 正常工作
  }}
/>

错误实现(导致黑屏):

// index.tsx - 直接在 render 回调中使用 Hook
<Column
  title="操作"
  dataIndex="action"
  key="action"
  render={(_: any, record: any) => {
    // ❌ 错误:在回调函数中使用 Hook
    const [editOpen, setEditOpen] = useState(false);

    const onEdit = () => {
      setEditOpen(true);
    };

    return (
      <>
        <Button type="link" onClick={onEdit}>
          编辑
        </Button>
        <EditModal isModalOpen={editOpen} setIsModalOpen={setEditOpen} />
      </>
    );
  }}
/>

🔍 问题原因分析

根本原因

违反了 React Hooks 的使用规则:Hooks 只能在 React 函数组件的顶层调用,不能在普通函数、回调函数、循环或条件语句中调用。

技术细节

  1. render 回调的本质

    • Table.Columnrender 属性接收的是一个普通函数,不是 React 组件函数
    • 这个函数在每次渲染时被调用,用于生成每一行的渲染内容
    • React 无法在这个函数执行上下文中追踪 Hook 的调用顺序
  2. React Hooks 的工作原理

    • Hooks 依赖于 React 的内部机制来维护状态和副作用
    • React 通过调用顺序来识别和管理每个 Hook
    • 当 Hook 在非组件函数中调用时,React 无法正确建立 Hook 的调用链,导致运行时错误
  3. 错误表现

    • 开发环境:控制台报错 Invalid hook call. Hooks can only be called inside the body of a function component.
    • 页面表现:白屏/黑屏(React 错误边界捕获错误,导致整个组件树崩溃)

错误信息示例

Error: Invalid hook call. Hooks can only be called inside the body of a function component.
This could happen for one of the following reasons:
1. You might have mismatching versions of React and the renderer (such as React DOM)
2. You might be breaking the Rules of Hooks
3. You might have more than one copy of React in the same app

✅ 解决方案

方案一:保持子组件独立(推荐)

优点:

  • 符合 React 组件化设计原则
  • 每行数据拥有独立的状态管理
  • 代码清晰,易于维护和测试
  • 性能优化:可以单独对子组件进行 memo 优化

实现:

// editButton.tsx
export default function EditButton({ data }) {
  const [editOpen, setEditOpen] = useState(false);
  // ... 其他逻辑
  return (/* ... */);
}

// index.tsx
<Column
  title="操作"
  render={(_: any, record: any) => {
    return <EditButton data={record} />; // ✅ 正确
  }}
/>

方案二:在父组件顶层管理状态

如果需要在父组件层面统一管理弹窗状态(例如:同一时间只允许打开一个弹窗),可以在父组件顶层使用 Hook:

// index.tsx
export function PolicyTextConfig() {
  // ✅ 在组件顶层声明状态
  const [editingRecord, setEditingRecord] = useState<any>(null);
  const [editOpen, setEditOpen] = useState(false);

  const handleEdit = (record: any) => {
    setEditingRecord(record);
    setEditOpen(true);
  };

  return (
    <>
      <Table dataSource={tableData}>
        <Column
          title="操作"
          render={(_: any, record: any) => {
            // ✅ render 回调中只调用普通函数,不使用 Hook
            return (
              <Button type="link" onClick={() => handleEdit(record)}>
                编辑
              </Button>
            );
          }}
        />
      </Table>

      {/* ✅ 弹窗放在组件顶层 */}
      <EditModal
        isModalOpen={editOpen}
        setIsModalOpen={setEditOpen}
        data={editingRecord}
      />
    </>
  );
}

📚 知识点总结

React Hooks 规则

  1. 只在顶层调用 Hook

    • ✅ 在函数组件的最顶层
    • ✅ 在自定义 Hook 的最顶层
    • ❌ 不在循环、条件或嵌套函数中
    • ❌ 不在普通回调函数中
  2. 只在 React 函数中调用 Hook

    • ✅ React 函数组件
    • ✅ 自定义 Hook
    • ❌ 普通 JavaScript 函数
    • ❌ 事件处理函数
    • ❌ 渲染回调函数(如 rendermap 回调等)

常见陷阱场景

场景 是否正确 说明
组件顶层 useState 正确用法
自定义 Hook 中 useState 正确用法
map 回调中 useState 错误:普通函数
onClickuseState 错误:事件处理函数
Table.Column renderuseState 错误:渲染回调函数
条件语句中 useState 错误:违反调用顺序规则

🎯 最佳实践

  1. 组件化优先

    • 需要状态管理的 UI 片段,优先提取为独立组件
    • 保持组件的单一职责原则
  2. 状态提升 vs 状态下沉

    • 如果多个组件需要共享状态 → 状态提升到父组件
    • 如果状态只属于单个组件 → 保持在组件内部
  3. 代码审查检查点

    • 检查所有 Hook 调用是否在组件顶层
    • 检查是否有在回调函数中使用 Hook 的情况
    • 使用 ESLint 插件 eslint-plugin-react-hooks 自动检测

🔧 调试技巧

  1. 查看控制台错误

    • React 开发模式会给出明确的错误提示
    • 关注 "Invalid hook call" 相关错误
  2. 使用 React DevTools

    • 检查组件树结构
    • 查看 Hook 调用情况
  3. ESLint 配置

    {
      "plugins": ["react-hooks"],
      "rules": {
        "react-hooks/rules-of-hooks": "error",
        "react-hooks/exhaustive-deps": "warn"
      }
    }
    

📝 总结

这次踩坑的核心教训是:React Hooks 有严格的调用规则,任何违反规则的使用都会导致运行时错误。 在 Ant Design Table 的 render 回调中,应该只返回 JSX 或调用普通函数,而将需要 Hook 的逻辑封装在独立的组件中。

通过这次问题,我们更加理解了:

  • React Hooks 的设计原理和限制
  • 组件化设计的重要性
  • 代码重构时需要保持对 Hook 规则的敏感性

前端向架构突围系列 - 基建与研发效能 [10 - 1]:物料体系的工程化治理

2026年2月11日 14:00

前言

很多公司吹嘘自己有“自研组件库”,点开一看,其实就是把 Ant Design 的按钮改了个颜色,再套个壳。

真正的物料基建,不是为了解决“按钮长什么样”,而是为了解决“为什么我的项目里有 18 个逻辑一模一样的搜索框,且没一个能直接复用”。如果你的基建不能让业务开发在面对 PM 的奇葩需求时少写 50% 的代码,那它就是个摆设。

image.png


一、 认知突围:物料是“业务逻辑”的载体

在架构师眼中,物料的治理不应停留在 UI 视觉层,而应深入到业务语义层

1.1 从“工具包”到“资产库”

  • UI 组件 (Low Level): 解决的是“样式统一”。(如:Modal, Select)

  • 业务物料 (High Level): 解决的是“行为统一”。

    • 例子: “用户选择器”不仅是一个下拉框,它背后关联着:接口鉴权、防抖搜索、分页加载、头像渲染。
    • 深度治理: 如果每个业务线都自己写一遍这套逻辑,那就是 10 倍的维护成本。

1.2 为什么大部分物料库会走向“腐烂”?

  • 过度封装: 为了支持所有场景,给一个组件开了 50 个 Props,最后代码里全是 if/else
  • 文档滞后: 开发者看文档像是在猜灯谜,最后发现“看源码比看文档快”。
  • 版本割裂: 核心库升级了,业务线不敢升,最后全公司跑着 5 个版本的组件库。

二、 深度工程化:物料的“生产流水线”

要让物料体系真正流转起来,架构师必须构建一套**“非人治”**的自动化链路。

2.1 基于 AST 的“自动化元数据提取”

别再让开发者手写文档了。利用 TypeScript 的编译器 API(Compiler API),在物料发布时自动扫描源码:

  • 自动提取 Props 定义、注释、默认值。
  • 自动生成 API 表格。
  • 自动识别依赖项。
  • 意义: 确保“代码即文档”,从根源上消灭文档与代码不一致的问题。

2.2 视觉回归测试:基建的“保险杠”

在企业级治理中,你最怕的就是:改了 A 组件的一个边距,结果 B 业务线的老页面直接塌陷了。

  • 方案: 引入 Visual Regression Testing(如 Playwright + Pixelmatch)。
  • 实战: 在 CI 环节,自动化对比组件修改前后的像素差异。哪怕只是偏移了 1px,也要在 PR 阶段被拦截。

三、 治理逻辑:如何让物料“好找且敢用”

3.1 建立“物料索引市场” (Discovery System)

如果一个物料不能在 30 秒内被开发者搜到,那它就不存在。

  • 智能搜索: 不止搜名称,更要搜“功能描述”。(搜“上传照片”,能关联出“图片裁剪”和“头像上传”)。
  • 在线 Sandbox: 必须提供即时预览代码试运行。开发者应该在“买”之前,先在浏览器里把玩一下。

3.2 影子测试与灰度策略

核心物料升级时,利用 Babel 插件Webpack 插件,在编译阶段分析业务代码的覆盖情况。

  • 深度实践: 统计哪些业务方使用了该物料的哪些属性。如果某属性没有任何人用,直接在下一版本废弃(Deprecate),保持物料库的“轻盈”。

四、 架构师的权衡:标准化 vs 灵活性

这是一个经典的架构陷阱:物料封装得越死,复用性越高,但灵活性越差。

4.1 经典陷阱:“千手观音”组件

想象一下,你们团队需要一个“开关 (Switch/Toggle)”组件。

起初(标准化阶段): 基础架构组设计了一个极其标准的 <StandardSwitch />。它只有两个属性:checkedonChange。样式是写死的:圆角、蓝色背景。大家用得很开心,规范统一。

后来(灵活性需求爆发):

  • 业务线 A:我们的产品主色调是红色,能改颜色吗?
  • 业务线 B:我们要搞促销活动,这个开关得是方形的,里面还要加个文字图标。
  • 业务线 C:我们需要把开关放在一个极小的空间里,尺寸能自定义吗?

结果(架构腐化): 为了满足这些需求,<StandardSwitch /> 被迫增加了几十个 Props:color, borderRadius, size, showIcon, iconContent...

最终,这个组件变成了一个长着无数只手的“千手观音”,内部充斥着复杂的样式判断逻辑,维护成本极高,且性能堪忧。

4.2 破局思路:Headless (无头) 组件

Headless 的核心思想是:将“逻辑的脑子”与“渲染的皮囊”彻底分离。

  • 有头组件 (Traditional): 买电脑送显示器。你想换个 4K 屏?对不起,主机和屏幕焊死在一起了。
  • 无头组件 (Headless): 只卖主机。你爱接 4K 屏、带鱼屏还是投影仪,随你便。

架构图解:分离的艺术

我们来看下 Headless 模式下,组件的分层架构:

如上图所示:

  • 底层 (Headless 逻辑层): 封装了所有“脏活累活”。比如,开关的状态切换、按空格键触发切换、盲人阅读器的 aria-checked 属性支持等。这些逻辑是通用的,与 UI 无关。
  • 顶层 (UI 渲染层): 完全由业务方自己决定。他们可以使用 <div>, <span>, CSS-in-JS, Tailwind CSS,想画成圆的就画成圆的,想画成方的就画成方的。

4.3 代码实例:从“千手观音”到“灵活组装”

我们用 React Hooks 来演示一下这个转变(Vue 的 Composition API 同理)。

场景:实现一个 Switch 开关

1. 定义 Headless Hook (只管逻辑):

这个 Hook 包含了开关的所有核心能力,但不涉及任何 DOM 和 CSS。

// useSwitch.ts (物料库提供)
import { useState, useCallback } from 'react';

export function useSwitch(initialState = false) {
  // 1. 状态管理
  const [isOn, setIsOn] = useState(initialState);

  // 2. 交互逻辑
  const toggle = useCallback(() => setIsOn(v => !v), []);

  // 3. 辅助功能 (A11y) 属性生成器
  const getSwitchProps = () => ({
    role: 'switch',
    'aria-checked': isOn,
    tabIndex: 0,
    onClick: toggle,
    onKeyDown: (e: React.KeyboardEvent) => {
      if (e.key === ' ' || e.key === 'Enter') {
        e.preventDefault();
        toggle();
      }
    }
  });

  // 只返回状态和逻辑方法
  return { isOn, toggle, getSwitchProps };
}

2. 业务方 A 的实现(标准圆角蓝风格):

业务方拿到了逻辑,自己决定怎么渲染。

// BusinessA_Switch.jsx (业务方 A 自定义)
import { useSwitch } from '@my-org/hooks';
import styled from 'styled-components';

const StyledButton = styled.button`
  background-color: ${props => props.isOn ? 'blue' : 'gray'};
  border-radius: 9999px; // 圆角风格
  // ... 其他样式
`;

export function StandardSwitch() {
  // 使用 Headless 能力
  const { isOn, getSwitchProps } = useSwitch();

  return (
    // 将逻辑属性解构赋值给 UI 元素
    <StyledButton isOn={isOn} {...getSwitchProps()}>
      <span className="thumb" />
    </StyledButton>
  );
}

3. 业务方 B 的实现(方形红色促销风格):

业务方 B 可以完全复用逻辑,画出截然不同的 UI。

// BusinessB_PromoSwitch.jsx (业务方 B 自定义)
import { useSwitch } from '@my-org/hooks';

export function PromoSwitch() {
  const { isOn, getSwitchProps } = useSwitch();

  return (
    // 使用 Tailwind 编写完全不同的方形样式
    <div
      {...getSwitchProps()}
      className={`${isOn ? 'bg-red-500' : 'bg-zinc-300'} w-16 h-8 rounded-none flex items-center cursor-pointer`}
    >
       <div className="bg-white w-6 h-6 mx-1 rounded-none">
         {isOn ? '开' : '关'}
       </div>
    </div>
  );
}

4.4 总结

通过 Headless 模式,架构师完成了对权力的完美让渡:

  • 架构师守住了底线: 核心交互逻辑、状态流转、可访问性标准被统一封装,不会因为业务方的 UI 定制而产生逻辑 Bug。
  • 业务方得到了自由: 他们再也不用为了改个颜色而去求基础架构组加 Props 了。

这就是那句格言的深层含义:

“给业务方留一扇窗(UI 自定义能力),他们就不会想拆掉你的墙(核心逻辑封装)。”


五、 总结:从“重复造轮子”到“按需组装”

5.1 研发复利:架构师的“长期主义”

在一般的团队中,工作量是随项目数量线性增长的;而在拥有顶级物料治理的团队里,工作量曲线应该是对数级的。

研发成本=首次沉淀成本+(极低的单次复用成本+边际维护成本)研发成本 = \text{首次沉淀成本} + \sum (\text{极低的单次复用成本} + \text{边际维护成本})

  • 初期(高投入): 你可能花了 2 周才磨合出一个完美的、Headless 架构的“财务大搜表”物料。
  • 后期(高回报): 当公司要开 5 个新的后台管理页面时,开发者只需要花 10 分钟引入物料并配置 Schema。由于物料已经在 100 个场景下跑过,其**健壮性(Robustness)**是任何新写的代码都无法比拟的。

5.2 案例对比:从“冷启动”到“一键飞升”

我们来看一个实际的业务场景:实现一个带“权限控制”和“自动重试”功能的图片上传组件。

维度 传统“造轮子”模式 (No Infrastructure) “按需组装”模式 (Material Asset)
开发耗时 3 - 5 小时(找文档、调 API、写逻辑、调样式) 5 - 10 分钟(拖拽组件,填写 API Key)
代码量 150+ 行(逻辑散落在各个组件中) 3 行(纯声明式配置)
稳定性 极低(不同人写的代码,异常处理逻辑不一) 极高(物料自带熔断、重试、OSS 分片逻辑)
可维护性 噩梦(后端 API 一改,全项目全局搜索替换) 轻松(物料中心统一升级,全线同步生效)
  • 向上管理: 用数据告诉老板,你搞的基建到底省了多少钱(换算成工时)。
  • 向下优化: 如果某个物料的使用率为 0,说明要么是不好用,要么是没推广,架构师应及时止损,将其踢出资产库。

结语:交付的最后一公里

研发效能不是看你写代码有多快,而是看从“代码提交”到“用户可用”的总时长(Lead Time)。

1. 现状对比:手工业 vs. 工业化流水线

如果交付链路不打通,你的基建就像是在泥潭里开跑车。

环节 人肉运维 (手工时代) DevOps 交付 (架构时代)
构建 开发者在本地执行 npm build,环境不一致导致“我本地明明是好的”。 云端环境统一构建(Docker 镜像),保证 100% 环境一致。
部署 找运维开权限,手动 FTP 上传或 SSH 到服务器执行 git pull 合入即部署。代码通过测试自动触发部署,开发者无需关注服务器。
配置 手动修改 Nginx 转发规则、配置跨域、刷新 CDN 缓存。 配置即代码 (IaC) 。Nginx 和路由配置随代码版本走,一键生效。
回滚 “快!把刚才那个备份压缩包覆盖回去!”(手忙脚乱中可能覆盖错版本)。 秒级回滚。通过镜像版本切换,一键回到任何稳定时刻。

2. 图解:交付链路的“能量损耗”

我们可以用一个“水管模型”来理解交付瓶颈:

警示:

如果中间那段“交付管道”是细窄的、堵塞的,那么你前端基建做得再大(漏斗再宽),最终流向用户的价值流速依然由那根细管子决定。

万字长文之——AI落地规范

作者 codingWhat
2026年2月11日 13:49

AI已经走入普通大众的生活,那么在程序员的世界中呢?我想,随着AI的浪潮滚滚前进,我们的工作中一定或多或少使用到了AI工具。比如一开始的vscode中的代码提示插件,如GitHub Copilot、通义灵码等,到现在的代码生成编辑器,如Cursor、Trae等。

一开始尝试使用Cursor时,会不禁感叹,这个真牛啊!它真的有这么厉害?我不信,让我来挑挑刺:

你看,他生成的代码风格和现有工程不一致;
你看,你看,他需求理解歪了啊,需求你来看看,是不是理解错了;
你看,你看,你看,他每次生成的代码都不一致啊,这怎么维护啊,ababab~ ~ ~


那么,真要放弃AI吗?那肯定不能,毕竟能干活啊;那问题来了,怎么让他更趁手些呢?在努力近一年后,我尝试写一写在现有工程中让 AI 辅助开发能够稳定落地的一些规范与实践。


一、为什么需要「AI 落地规范」

在真实业务项目里引入 AI(如 Cursor、Claude Code)写代码时,常遇到:

  • 生成代码风格与现有工程不一致:目录、命名、组件结构各写各的。
  • 需求理解偏差:缺少结构化需求与接口说明,AI 只能“猜业务”,更容易出现幻觉。
  • 可维护性差:没有统一规则,后人或 AI 难以在既有约定下续写。

要解决这些问题,需要两件事同时到位:

  1. 输入规范:需求、接口、配置等写成「AI 可读、可执行」的结构化文档。
  2. 输出规范:用项目规则(如 Cursor Rules)约束 AI 的目录、命名、组件与接口写法。

下面以一个vue项目中的模块「xxxx缴费记录查询」和 xxx-xxx-h5-rules 为例,说说我们团队是如何做的。


二、输入规范:把需求写成 AI 读得懂的语言

AI 落地的前提是:需求与接口被清晰、结构化地写出来,而不是只存在于口头上或零散的聊天里(比如多轮对话,这样只会让代码成为一坨,,,)。

2.1 功能文档结构

xxxx缴费记录查询/
├── xxxx缴费记录查询-需求.md         # 功能概述、流程、页面与规则
├── xxxx缴费记录查询接口-需求.md     # 接口入参、出参、取值与规则
├── 系统参数配置.md                # 提示信息、开关等可配置项
└── 原型文件/                     # HTML/CSS/JS 原型
  • 需求文档(前端用):功能概述、流程图(如 Mermaid)、页面分区(导航栏、查询区、列表区)、控件表(名称、类型、必录、规则、数据元)、页面规则(如 4.1 页面初始化、4.2 xx数据查询规则)。
  • 接口需求文档(后端用):接口概述、入参/出参表(含数据元标识、字段名、类型、是否可空)、接口规则(入参校验、表来源、取值规则)。
  • 系统参数配置(共用):按模块/页面列出可配置项(如主页面提示信息),便于前端从配置读取而非写死。

这类结构让 AI 能够:按「页面 + 控件 + 规则」生成页面与交互;按「入参/出参 + 规则」生成 API 调用与 DTO;按「配置项」生成从 store/config 读取的提示或开关。


2.2 AI下的需求文档该怎么写

统一使用对AI更友好的.md文档编写需求,这对需求侧的同学也是一个不小的挑战,但我们认为这是很有必要的!

一、功能概述

用一两句话说明功能面向谁、做什么、范围是什么。例如:

本功能支持xxx

  • 对 AI 的用途:确定模块边界、页面标题、路由命名。

二、详细流程

先画流程图(建议用 Mermaid),再配简短流程说明。例如:

sequenceDiagram
    actor 用户
    participant 本功能 as 本功能
    participant 外部系统 as 外部系统
    rect rgba(135, 206, 250, 0.4)
        Note over 用户,外部系统: 1. 【页面初始化】
        用户->>本功能: 1) 访问xxxx缴费记录查询页面
        本功能->>外部系统: 引用4.1规则初始化
        外部系统-->>本功能: 返回初始化配置及数据
        本功能-->>用户: 展示xxxx缴费记录
    end

流程说明示例:

  1. 【页面初始化】访问功能后,系统获取个人信息及缴费列表数据,展示xxxx缴费记录查询主页。引用 4.1 规则初始化。
  • 写法要点:流程与后文「四、页面规则说明」中的规则编号对应(如“引用 4.1”),便于实现时按规则写初始化逻辑。
  • 对 AI 的用途:生成 mounted/created 中的调用顺序,知道先取配置再取列表。

三、页面需求分析

页面 → 区域 → 控件分层,用表格把每个区域的控件写清楚。

  • 导航栏:操作按钮 + 标题。用表格式列出控件名称、可操作、控件类型、交互事件及规则;标题单独一行说明。

  • 页面提示区域:不写死文案,而是引用配置。例如:

    提示内容取自「系统参数配置.md」,具体路径:xxxx缴费记录查询 → 主页面提示信息参数配置,若未配置则不显示。

    这样 AI 会生成「从配置读取,未配置则不展示」的逻辑,而不是写死一段文字。

  • 查询条件选择区域:用一张表统一描述控件,列建议包含:

    列名 说明与示例
    控件名称 xxx
    控件类型 年度选择框、下拉框
    可操作/必录/是否可见 Y/N,便于做校验与展示
    初始状态/数据 默认当前系统年度、暗文「请选择」
    操作规则及取数口径 格式 YYYY、最大不超过当前年度、下拉选项:xxxx;引用“xx数据查询规则”
    数据元字段名 与接口对齐

    示例(节选):

    控件名称 控件类型 可操作 必录 是否可见 初始状态/数据 操作规则及取数口径
    年度 年度选择框 Y Y Y 默认显示当前系统年度 格式 YYYY;最大不能超过当前系统年度。引用“xx数据查询规则、xx组件”
    xx 下拉框 Y N Y 暗文:请选择 下拉选项:xxx。引用“xx数据查询规则”
  • 列表区域:同样用表列出「展示项 + 取数口径 + 数据元」。例如缴费金额写清单位、格式(¥、保留两位小数)。
    这样 AI 能直接生成列表列定义和格式化逻辑。

四、页面规则说明

编号规则把「页面初始化」「查询规则」等说清楚,并与前文引用一致。

  • 4.1 页面初始化:逐条写“根据哪份配置做哪件事”“根据哪条规则初始化数据”。例如:

    • 根据《系统参数配置.md》对“页面提示区域”进行初始展示。
    • 根据“xx数据查询规则”初始化展示页面数据。
  • 4.2 缴费数据查询规则

    • 先写查询条件转换
    • 再写调用哪个接口。
      AI 可根据规则生成查询参数组装和接口调用。

2.3 我们为什么规定这样写需求文档?

维度 写法 好处
结构化 功能概述 → 流程 → 页面分析 → 规则说明;接口概述 → 入参/出参 → 接口规则 层次清晰,AI 和人都能按章节定位;先“做什么”再“怎么做”,减少遗漏。
表格化 控件表、入参表、出参表、代码映射表 机器可读性强,AI 可直接用于生成表单字段、请求参数、列表列、常量映射;前后端对齐同一张表,字段名和类型一致。
引用与编号 “引用 4.1 规则”“引用xxxz组件”“引用xx数据查询规则”“根据系统参数配置.md” 需求内可追溯,实现时按编号找到对应规则及组件;提示区、查询逻辑等不写死,便于多地区配置。
数据元与取数口径 数据元标识符、字段名、取数口径、格式备注 与标准数据元一致,利于对接;取数口径让前端知道哪些字段是后端计算、如何展示。
流程 + 规则 Mermaid 流程图 + 规则编号 先有时序再有条文,AI 能生成正确的调用顺序和初始化逻辑;规则可单独实现、单独测试。
配置与代码分离 提示内容取自系统参数配置、若未配置则不显示 文案和开关可配置,AI 生成“读配置再展示”的代码,避免硬编码,便于多环境。

整体上,需求文档按“概述 → 流程 → 页面/接口表格 → 规则说明”来写,并用表格和编号把控件、字段、映射、规则、组件编号固定下来,既能让人一眼看懂,也能让 AI 按表生成代码、按规则生成逻辑,减少歧义和返工,也方便后续的1—N迭代。


2.4 需求文档中建议包含的要素

要素 说明 对 AI 的用途
功能概述 一两句话说清功能做什么、谁用 确定模块边界、路由/菜单命名
流程图 用 Mermaid 等画清主流程 生成顺序逻辑、调用关系
页面分区 导航栏、提示区、查询区、列表区等 对应 template 区块与组件
控件表 控件名称、类型、必录、可见性、初始值、操作规则、数据元 生成表单项、校验、字段绑定
数据元 中文名、标识符、字段名、类型、长度 与接口字段、DTO 对齐
页面规则 如「4.1 页面初始化」「4.2 xx数据查询规则」 生成 mounted/created 逻辑与查询条件转换

接口需求文档中则建议包含:

  • 接口概述、入参/出参表(含数据元与检索策略);
  • 入参校验、涉及表、关键取值规则。

这样 AI 生成的 API 封装、请求参数和 DTO 才能与后端约定一致。

2.5 系统参数配置的用法(可选)

「系统参数配置.md」中按模块列出「主页面提示信息参数配置」等,并说明使用场景、配置项、默认值。
前端应:从配置读取提示语等,而不是在页面里写死。这样 AI 生成的代码会去读 state.configs 或等价配置,便于多地区/多环境差异化。


三、输出规范:用项目规则约束 AI 的代码

有了清晰输入,还需要统一输出:目录、命名、组件结构、API 与状态管理方式都要和现有项目一致。本项目中通过 xxx-xxx-h5-rules 承担这一角色。

3.1 规则体系概览(xxx-xxx-h5-rules)

规则文件 作用 对 AI 的约束
project-structure.mdc 项目结构、入口、路由位置 新模块放在哪、如何拆目录
vue-coding-standards.mdc Vue 组件顺序、JS 规范、路由/Vuex/API 约定 组件结构、命名、mapState/mapMutations 用法
component-development.mdc 通用/业务组件、标准父/子页面结构 页面用 xxx + keep-alive、子页面 name 与 class 命名
api-integration.mdc HTTP 封装、接口组织、错误与加密 api 模块写法、http 使用方式
business-modules.mdc 业务模块列表、新增模块步骤 路由/store/api 注册顺序与命名
build-deployment.mdc 构建、环境、部署 不随意改构建与公共路径

这些规则应被 Cursor 等工具识别(如通过 .cursor/rules 或说明中引用),让 AI 在生成代码时默认遵循。

3.2 部分 mdc 文档说明

规则文件不只有代码,而是「说明 + 列表 + 目录树 + 代码块」组合。下面按文件摘录部分原文,供大家参考。


README.mdc(总览)

---
alwaysApply: true
description: xxx-xxx-h5 项目规则总览
---

# xxx-xxx-h5 项目规则总览

## 规则文件说明

本目录包含 xxx-xxx-h5 项目的所有 Cursor 规则文件,用于指导 AI 助手更好地理解和开发该项目。

### 规则文件列表

1. **project-structure.mdc** - 项目结构指南
   - 项目概述和核心目录结构
   - 模块化架构特点说明
   - 主要入口文件和配置文件位置

2. **vue-coding-standards.mdc** - Vue 2.x 编码规范
   - Vue 组件开发规范
   - JavaScript 编码标准
   - 路由和状态管理规范
   ...
  • frontmatter 声明 alwaysApply: truedescription,便于 Cursor 按需加载或常驻。
  • 编号列表 + 子项 说清每个规则文件负责什么,AI 能快速判断该查哪个文件。

project-structure.mdc(结构说明)

# xxx-xxx-h5 项目结构指南

## 项目概述
xxx-xxx-h5 是一个基于 Vue 2.x 的 H5 应用,采用模块化架构设计。

## 核心目录结构

### 主要入口文件
- main.js - 应用主入口,配置 Vue 实例和全局插件
- App.vue - 根组件
- vue.config.js - Vue CLI 配置文件

### 路由系统
- router/index.js - 主路由配置,包含所有模块路由
- router/ - 各功能模块的路由配置
...

## 模块化架构特点
项目采用功能模块化设计,每个业务功能都有对应的:
- 路由配置 (router/)
- 状态管理 (store/)
- API 接口 (api/)
- 页面组件 (views/)

这种设计便于维护和扩展新功能。
  • 先概述再拆目录AI 能建立「模块 = router + store + api + views」的心智模型
  • 无代码也可约束「新模块该建哪些目录、放在哪」。

vue-coding-standards.mdc

---
globs: *.vue,*.js
description: Vue 2.x 编码规范和最佳实践
---

### 组件命名
- 组件名使用 PascalCase
- 文件名与组件名保持一致

### 路由命名
- 路由名称使用 camelCase
- 路径使用 kebab-case
- 模块路由统一导入到主路由
- 使用常量导出路由名称,便于维护
  • globs 限定只在 *.vue,*.js 下生效,避免在无关文件中误用。
  • 条文式约定(命名、路径、常量)无需代码也能约束生成结果。

同一文件内再配「获取系统配置」的说明 + 注意事项 + 代码:规则里先写「在组件中获取系统配置必须使用以下方式」,再贴代码;接着用注意事项列出四条(用户信息走 getters、系统配置走 state、箭头函数、避免在 methods 里访问 store)。示例代码:

// vue-coding-standards.mdc 中规定的方式
export default {
  computed: {
    sbfConfigs: (vm) => {
      return vm.$store.state.configs.xxx
    }
  }
}
  • 先写「必须使用以下方式」再贴代码,AI 会优先采用该写法。
  • 注意事项 能减少「能跑但不符合项目习惯」的生成(如把 config 放在 methods 里)。

component-development.mdc

### 组件设计原则
- 单一职责:每个组件只负责一个功能
- 可复用性:组件应该可以在不同场景下复用
- 可配置性:通过 props 提供灵活的配置选项
- 可扩展性:支持插槽和事件扩展

### 标准页面组件结构
- 页面组件放在 `views/` 目录
- 按功能模块组织子目录
- 组件名使用 `moduleName.pageName` 格式
- 父级组件使用 `xxTransition``keep-alive`
  • 设计原则 是纯文字,但能引导 AI 不写「大而全」的单文件页面。
  • 标准页面组件结构 四条直接对应「放哪、怎么命名、用什么包裹」,和后面的代码块一致。

api-integration.mdc

### 模块化 API 结构
api/
├── index.js              # API 主入口
├── account.js            # 账户相关接口
├── apply.js              # 申请相关接口
├── basecode.js           # 基础代码接口
├── pay.js                # 支付相关接口
└── ...                   # 其他业务模块接口

### 核心 HTTP 配置
- core/http.js - 基础 HTTP 配置
- core/httpEncrypt.js - 加密 HTTP 配置
- core/BusinessError.js - 业务错误处理
  • 目录树 让 AI 知道新模块的 api 文件应放在哪、如何与 index 挂载。
  • 核心 HTTP 配置 列出文件与职责,生成请求代码时会联想到用 @/core/http、BusinessError。

3.3 这样写 mdc 规则的好处

写法 好处
frontmatter(alwaysApply / globs / description) 工具可按路径或全局决定何时注入规则,减少无关上下文;description 便于人和 AI 快速理解文件用途。
分文件(结构 / Vue / 组件 / API / 业务 / 构建) 单文件短小、职责清晰,AI 检索时容易命中「路由」「组件」「API」等关键词;后续增删规则不影响其他领域。
说明 + 列表 + 代码一起写 既有「必须」「应」等约束性文字,又有可复制的模板代码,AI 既知道「为什么这样」又知道「长什么样」,生成时更少偏离。
注意事项 / 命名规范等条文 不依赖代码也能约束命名、数据来源(如 config 只从 computed 取)、错误处理方式,避免「风格正确但细节不符合项目习惯」。
目录树与入口说明 明确新模块该建哪些目录、在 index 里如何挂载,AI 生成的目录和 import 关系更一致。
标准模式 + 复杂模式并存 简单业务用标准模式(reset/set、queryList),复杂业务用 actions/getters,AI 能按需求选模板,而不是自己发明一套。

整体上,**把规则写成「可执行的说明 + 可粘贴的代码」**,并配合 frontmatter 与分文件组织,能让 AI 在正确时机、正确范围内套用约定,减少反复修改和风格漂移,也便于新人或后续 AI 继承同一套规范。

四、总结

  1. 新功能先写清需求与接口
    至少具备:功能需求(含流程与页面分析)、接口需求(入参/出参/规则)、与前端相关的系统参数配置。

  2. 把项目规则显式化
    像 xxx-xxx-h5-rules 一样,把结构、Vue、组件、API、业务模块、构建拆成独立规则文件,并让 AI 在上下文中能读到这些规则。

  3. 模块命名与注册统一
    业务模块用拼音首字母;新增模块严格按 router → store → api → views 的顺序注册,并在主入口中挂载。

  4. 配置与数据元不写死
    提示语、开关等走系统参数配置;字段名、数据元与接口文档一致,便于前后端联调与 AI 二次修改。

  5. 用现有模块当模板
    让 AI 参考历史模块的 router/store/api/views 实现方式,再结合需求与接口文档生成新模块,可减少风格偏差。


五、结语

AI 要在现有工程里稳定落地,不能只靠「说人话」提需求,而要:

  • 输入侧把需求、接口、配置写成结构化、可检索的文档,后期可引入向量库编排
  • 输出侧用项目规则(如 xxx-xxx-h5-rules)约束目录、命名、组件、API 和状态管理

AI 落地规范是一个系统化工程,本文仅介绍了需求侧和代码生成侧部分,还有很多地方没有提到,谨供大家参考。

Wujie 微前端架构下的跨应用状态管理实践:Props + Bus 双通道方案

2026年2月11日 13:38

在基于 Wujie 的微前端架构中,主应用与子应用之间的状态同步是一个绑不开的难题。本文分享我们在生产项目中的实践:如何用最简洁的方式,实现系统级数据的单一可信源和实时同步,同时避免过度设计的陷阱。

背景

我们的项目是一个典型的企业级 B 端系统,采用 Vue 3 + Pinia + Wujie 的微前端架构:

  • 1 个主应用:负责登录、菜单、布局、系统级数据管理
  • 6+ 个子应用:各自独立开发部署,通过 Wujie 嵌入主应用运行

技术栈:Vue 3.5 / Pinia 3 / Vite 7 / TypeScript 5 / Wujie 1.0

问题:6 个子应用,6 份重复数据

随着子应用数量增长,我们遇到了一系列状态管理问题:

1. 字典数据 N+1 次请求

主应用启动时请求一次全量字典,但每个子应用启动后又各自请求一次。6 个子应用 = 7 次相同的接口调用。

2. 语言切换不同步

用户在主应用切换语言后,已加载的子应用仍然显示旧语言,必须刷新页面才能生效。

3. Token 刷新断裂

主应用静默刷新 Token 后,子应用持有的旧 Token 继续发请求,触发 401 错误。

4. 用户信息传递不完整

主应用通过 Wujie props 只传了 tokenuserInfo,但子应用还需要 permissions(权限列表)和 roles(角色树),只能自己再请求一次。

5. 数据源不唯一

同一份用户数据,在主应用的 Pinia Store 里有一份,在每个子应用的 Pinia Store 里又各有一份。数据不一致的风险始终存在。

归结为一句话:缺少一个统一的跨应用状态分发机制

方案选型:我们踩过的坑

尝试一:封装 npm 包(过度设计)

最初我们的方案是创建一个 @cmclink/shared-stores 基础包:

packages/shared-stores/
├── src/
│   ├── auth.ts        # useSharedAuth() composable
│   ├── dict.ts        # useSharedDict() composable
│   ├── locale.ts      # useSharedLocale() composable
│   ├── provider.ts    # createSharedStoreProvider()
│   ├── utils.ts       # isInWujie() / getWujieBus()
│   ├── types.ts       # 12 个类型定义
│   └── constants.ts   # 事件常量

看起来很"工程化",但实际落地后发现几个问题:

  1. 增加了复杂度却没带来多少收益。系统级数据本就由主应用维护,子应用只需要只读消费,多一个包多一层抽象,反而增加了理解成本和维护负担。
  2. Provider 的 watch 机制失效。我们设计了 createSharedStoreProvider(bus, source) 来监听 Store 变化,但 source.auth() 每次返回新的普通对象,Vue 的 watch 根本追踪不到响应式变化——整个广播机制是失效的。
  3. 子应用被迫依赖这个包。本来子应用只需要读 props 和监听 bus,现在还要安装一个额外的 npm 包。

最终方案:回归简洁

反思后我们确立了核心原则:

系统级数据由主应用独占维护,通过 Wujie 原生机制(props + bus)只读分发给子应用。不引入额外的包,不搞抽象层。

架构设计

Store 三层分类

┌─────────────────────────────────────────────────┐
│  Layer 0 — 系统级(主应用独占维护,子应用只读)     │
│  userStore / dictStore / localeStore             │
├─────────────────────────────────────────────────┤
│  Layer 1 — 应用级(主应用独有)                    │
│  tabsStore / messageStore / historyStore         │
├─────────────────────────────────────────────────┤
│  Layer 2 — 业务级(各子应用独有)                   │
│  orderStore / routeStore / blStore ...           │
└─────────────────────────────────────────────────┘

关键区分:Layer 0 的数据需要跨应用共享,Layer 1 和 Layer 2 不需要。只对 Layer 0 做状态分发,保持最小化。

双通道通信协议

利用 Wujie 自带的两个通信机制:

通道 机制 用途 特点
Channel 1 wujie props 冷启动初始快照 同步、可靠、子应用启动即可用
Channel 2 wujie bus 运行时增量同步 异步、实时、事件驱动

props 结构:

{
  $shared: {
    auth: { token, refreshToken, userId, permissions, roles, userInfo },
    dict: { dictMap },
    locale: { lang }
  },
  // 向后兼容旧字段
  token: '...',
  userInfo: { ... }
}

bus 事件:

事件 载荷 触发时机
SHARED:AUTH_UPDATED { token, permissions, roles, userInfo } 权限/角色/用户信息变更
SHARED:TOKEN_REFRESHED { token, refreshToken } Token 静默刷新后
SHARED:DICT_UPDATED { dictMap, version } 字典数据加载完成
SHARED:LOCALE_CHANGED { lang } 语言切换
SHARED:LOGOUT void 用户登出

两者互补:props 解决冷启动,bus 解决热更新

核心实现

整个方案的核心就一个文件:主应用的 shared-provider.ts

主应用侧:Provider

// apps/main/src/stores/shared-provider.ts
import { watch } from 'vue'
import { bus } from 'wujie'

// 事件常量就地定义,不引入额外包
const SHARED_EVENTS = {
  AUTH_UPDATED: 'SHARED:AUTH_UPDATED',
  TOKEN_REFRESHED: 'SHARED:TOKEN_REFRESHED',
  DICT_UPDATED: 'SHARED:DICT_UPDATED',
  LOCALE_CHANGED: 'SHARED:LOCALE_CHANGED',
  LOGOUT: 'SHARED:LOGOUT',
} as const

export function setupSharedStoreProvider(): void {
  const userStore = useUserStoreWithOut()
  const dictStore = useDictStoreWithOut()
  const localeStore = useLocaleStoreWithOut()

  // 直接 watch Pinia store 的响应式属性
  watch(
    () => ({
      permissions: userStore.permissions,
      roles: userStore.roles,
      roleId: userStore.roleId,
      userInfo: userStore.user,
    }),
    (newVal) => {
      bus.$emit(SHARED_EVENTS.AUTH_UPDATED, {
        token: getAccessToken(),
        ...newVal,
      })
    },
    { deep: true },
  )

  // 字典加载完成后广播
  watch(
    () => dictStore.isSetDict,
    (isSet) => {
      if (isSet) {
        bus.$emit(SHARED_EVENTS.DICT_UPDATED, {
          dictMap: dictMapToRecord(dictStore.dictMap),
          version: Date.now(),
        })
      }
    },
  )

  // 语言切换
  watch(
    () => localeStore.currentLocale.lang,
    (lang) => bus.$emit(SHARED_EVENTS.LOCALE_CHANGED, { lang }),
  )

  // $subscribe 兜底检测登出
  userStore.$subscribe(() => {
    if (!userStore.isSetUser && userStore.permissions.length === 0) {
      bus.$emit(SHARED_EVENTS.LOGOUT)
    }
  })
}

关键细节:直接 watch Pinia store 的响应式属性,而不是通过 getter 函数间接访问。这是我们踩过的坑——如果 watch(() => source.auth().token, ...) 中的 source.auth() 每次返回新对象,Vue 的响应式追踪会完全失效。

非响应式数据的处理

Token 存储在 sessionStorage(通过 wsCache),不是 Pinia 的响应式状态,无法用 watch 监听。我们的做法是在写入点主动广播

// apps/main/src/utils/auth.ts
export const setToken = (token: TokenType) => {
  wsCache.set(CACHE_KEY.REFRESH_TOKEN, token.refreshToken)
  wsCache.set(CACHE_KEY.ACCESS_TOKEN, token.accessToken)
  // Token 写入后主动广播(动态 import 避免循环依赖)
  import('@/stores/shared-provider').then(({ emitTokenRefreshed }) => {
    emitTokenRefreshed()
  })
}

Logout 同理,在 user.tslogout() action 中主动调用:

async logout() {
  await logout()
  removeToken()
  emitSharedLogout()  // 主动广播,确保子应用收到
  this.resetState()
}

初始快照注入

主应用的 App.vue 通过 computed 构建 props,每次 Store 变化自动更新:

<WujieVue
  v-for="app in loadedApps"
  :key="app.name"
  :props="sharedProps"
  :alive="true"
/>

<script setup>
const sharedProps = computed(() => ({
  $shared: {
    auth: { token, permissions, roles, userInfo, ... },
    dict: { dictMap },
    locale: { lang },
  },
  // 向后兼容旧子应用
  token: getAccessToken(),
  userInfo: userStore.user,
}))
</script>

子应用侧:只读消费

子应用不需要安装任何额外依赖,直接用 Wujie 原生 API:

// 冷启动:从 props 获取初始数据
const wujie = (window as any).__WUJIE
const shared = wujie?.props?.$shared

if (shared) {
  // 微前端环境:使用主应用的数据
  authStore.setToken(shared.auth.token)
  authStore.setPermissions(shared.auth.permissions)
  dictStore.setDictMap(shared.dict.dictMap)
  i18n.global.locale.value = shared.locale.lang
} else {
  // 独立运行:走本地 API
  await authStore.fetchUserInfo()
  await dictStore.fetchDictData()
}

// 热更新:监听 bus 事件
wujie?.bus?.$on('SHARED:TOKEN_REFRESHED', (data) => {
  authStore.setToken(data.token)
})
wujie?.bus?.$on('SHARED:LOCALE_CHANGED', (data) => {
  i18n.global.locale.value = data.lang
})
wujie?.bus?.$on('SHARED:LOGOUT', () => {
  authStore.clearLocal()
  router.push('/login')
})

通过 __WUJIE 是否存在来判断运行环境,微前端环境走 props/bus,独立运行走本地 API,子应用始终保持独立可运行。

数据流全景

┌──────────────────────────────────────────────────────────┐
│                        主应用                              │
│                                                          │
│  API 请求 → userStore / dictStore / localeStore          │
│                        │                                  │
│              shared-provider.ts                           │
│              (watch 响应式属性 → bus.$emit)                │
│                        │                                  │
│            ┌───────────┼───────────┐                      │
│            ▼           ▼           ▼                      │
│       wujie props   wujie bus   tabsStore 等              │
│       ($shared)     (SHARED:*)                            │
│            │           │                                  │
└────────────┼───────────┼──────────────────────────────────┘
             │           │
   ┌─────────┼───────────┼─────────┐
   ▼         ▼           ▼         ▼
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│ doc  │ │ ibs  │ │ mkt  │ │ ...  │
│      │ │      │ │      │ │      │
│ 只读 │ │ 只读 │ │ 只读 │ │ 只读 │
│ 消费 │ │ 消费 │ │ 消费 │ │ 消费 │
└──────┘ └──────┘ └──────┘ └──────┘

踩坑记录

坑 1:watch getter 返回新对象导致响应式失效

// ❌ 错误:每次调用 source.auth() 返回新对象,watch 追踪不到变化
watch(() => source.auth().token, (token) => { ... })

// ✅ 正确:直接 watch Pinia store 的响应式属性
watch(() => userStore.permissions, (permissions) => { ... })

这是 Vue 响应式系统的基本原理,但在抽象层过多时很容易忽略。getter 函数如果每次返回新的普通对象,Vue 无法建立依赖追踪

坑 2:dictStore.dictMap 是 Map 不是 Object

Pinia store 中字典数据用 Map<string, any> 存储,但跨 iframe context 传输时 Map 无法正确序列化。需要转换为普通 Record:

function dictMapToRecord(dictMap: any): Record<string, any[]> {
  const result: Record<string, any[]> = {}
  if (dictMap instanceof Map) {
    dictMap.forEach((value, key) => { result[key] = value })
  } else {
    Object.assign(result, dictMap)
  }
  return result
}

坑 3:Token 存在 sessionStorage 中,不是响应式的

Token 通过 wsCache(封装的 web-storage-cache)存储在 sessionStorage 中,不在 Pinia state 里,watch 监听不到。

解决方案:在写入点主动广播,而不是试图监听存储变化。用动态 import() 避免循环依赖。

坑 4:过度抽象的代价

最初我们设计了完整的 composable 层(useSharedAuthuseSharedDictuseSharedLocale),每个都有 fallback 机制、readonly 包装、onScopeDispose 清理。

看起来很优雅,但实际上:

  • 子应用只需要读 props + 监听 bus,10 行代码的事
  • 多了一个 npm 包依赖,子应用的 package.json 要加,CI 要装
  • composable 内部的 wujie 环境检测逻辑和子应用自己写没区别
  • 维护成本远大于收益

最终我们删掉了整个包,回归最简方案。

设计原则总结

原则 说明
主应用独占维护 系统级数据只在主应用写入,子应用只读
不过度设计 不搞 composable 抽象层、不搞额外 npm 包
用平台能力 Wujie 自带 props + bus,够用就不造轮子
在写入点广播 非响应式数据(Token)在写入时主动 emit
向后兼容 新增 $shared 字段,保留旧的 token/userInfo
独立可运行 子应用通过 __WUJIE__ 环境检测,非微前端环境走本地 API

效果

  • 接口调用:字典请求从 N+1 次降为 1 次
  • 语言切换:实时同步,无需刷新
  • Token 刷新:主应用刷新后 50ms 内所有子应用同步
  • 代码量:主应用新增 1 个文件(~180 行),子应用各减少 3 个冗余 Store
  • 依赖:零新增 npm 包

适用场景

这个方案适用于:

  • 基于 Wujie(或类似 iframe 沙箱方案)的微前端架构
  • 主应用是唯一的系统级数据管理者
  • 子应用数量 > 2,且共享用户/权限/字典/语言等全局数据
  • 团队希望保持架构简洁,避免过度工程化

不适用于:

  • 子应用之间需要双向通信的场景(本方案是单向只读分发)
  • 子应用需要修改系统级数据的场景(应该通过 bus 事件请求主应用修改)

本文基于 Wujie 1.0 + Vue 3.5 + Pinia 3 的生产实践,如有问题欢迎交流。

聊聊编程里的“魔法棒”:取余运算(Modulo)

2026年2月11日 13:35

💡 写在前面: 最近面试被问到一个倒计时相关问题,又一次用到了取余(Modulo)。说实话,刚入行那会儿,总觉得这玩意儿不就是小学数学里的求余数 吗?除了面试题里用来判断奇偶数,平时好像也没啥大用。

但随着代码写得越来越多,逐渐发现 % 符号背后其实隐藏着一种处理数据的思维模型——它能把无限延伸的线性世界,折叠成有限可控的 周期世界。今天想和大家分享一下我对取余的重新思考,看看它是怎么帮我们优雅地解决那些头疼的边界问题。

重新认识 %

取余的本质,是将任意数值强行限定在一个固定的循环范围内。无论数字跑多远,% N 都能让它回归到 0N-1 的闭环中。

在教科书里,取余的公式是 a % n = r

  • a:被除数
  • n:除数
  • r:余数

但在代码逻辑里,我更愿意把它理解为两个超级好用的思维模型:

🔄 循环

想象一下家里的挂钟。不管时间怎么流逝,时针转了一圈又一圈,它永远只会停在 112 之间。取余就是这个表盘 ,它能让无限增长的数字,乖乖地在一个固定的"圈"里打转。

✂️ 限制

无论你给我的数字有多大,% n 就像一把剪刀,强行把多出来的部分剪掉,只保留 0n-1 这一小段。

就可以理解为:

  • a:被除数(任意数值)
  • n:除数(限定的范围大小,也就是"表盘"的大小)
  • r:余数(结果永远在 0n-1 之间)

特点

构建"周期闭环"

说白了就是让数字一直在一个圈里转,永远跑不出去。比如轮播图或红绿灯,写 if (index >= length) 来防止数组越界,写多了特别烦。

有了取余,这事儿就简单了:

// 不管 index 涨到几万,结果永远锁死在 0 到 length-1 之间
const safeIndex = index % list.length;

降维与坐标映射

这个主要解决"一维变二维"的问题。比如为了省流量,后端扔过来一个长长的一维数组,你需要在界面上画个九宫格。

别傻乎乎地去搞双层循环,直接用数学搞定。假设一行有 col 列:

  • 找列号(X轴):看它在当前行走了几步 -> 取余 (% col)
  • 找行号(Y轴):看它已经填满了几行 -> 整除 (Math.floor(i / col))
// 假设数组索引 i=7,一行3个 (col=3)
const x = 7 % 3;                // 1 (第2列)
const y = Math.floor(7 / 3);    // 2 (第3行)

// 坐标就是 (1, 2)

均匀离散与分流

一大堆随机数据(比如 1000 万个用户 ID),把它们公平地分给 3 台服务器,怎么分最匀称?

别搞什么复杂的随机算法,直接按 ID 取余。这不仅分得匀,还能保证同一个用户每次都能分到同一台机器上(这在分布式里叫 Hash 一致性)。

  • 数字 ID:直接取余。
  • 字符串 ID:先算 Hash 值(转成数字),再取余。
// 简单又高效的负载均衡
const targetServer = servers[userId % 3];

// 如果是字符串 ID,就先转成数字(Hash)
// const hash = stringToNumber(userId); 
// const targetServer = servers[hash % 3];

声明式逻辑

代码是写给人看的。if-else 是告诉机器"怎么做流程控制",而 % 是告诉人"这里是个循环"。

% 最大的好处就是——你再也不会把 > 误写成 >= 了。那种差 1 的 Bug(Off-by-one error),写过代码的都懂有多坑。

倍数与规律捕捉

想每隔 10 行打个日志?或者给表格弄个"斑马纹"(奇偶变色)?

这种"每隔 N 次搞点事情"的逻辑,用取余是最直观的。它就像个节拍器,到了那个点就会响。

// 经典的斑马纹逻辑
const color = index % 2 === 0 ? 'white' : 'gray';

常见的面试题(由简到难)

1. 秒转时分秒(倒计时)

:给你一个总秒数 3661,怎么在页面上显示 01:01:01

:这是最基础的"进制转换"题。

  • 低位(秒):总秒数对 60 取余 -> 剩下的零头就是秒。
  • 中位(分):总秒数先除以 60 得到总分钟数,再对 60 取余 -> 剩下的零头就是分。
  • 高位(时):总分钟数除以 60 -> 剩下的就是时。
const totalSeconds = 3661;

const seconds = totalSeconds % 60;            // 1
const minutes = Math.floor(totalSeconds / 60) % 60; // 61 % 60 = 1
const hours = Math.floor(totalSeconds / 3600);      // 1

const format = time => time.toString().padStart(2, '0');
console.log(`${format(hours)}:${format(minutes)}:${format(seconds)}`); // 01:01:01

2. 判断质数(Prime Number)

:怎么判断一个数 n 是不是质数?

:质数就是只能被 1 和它自己整除的数。

所以,拿 2 到 n-1 之间的所有数去试着除它。只要有一个能被整除(n % i === 0),它就不是质数。

优化点:其实只需要试到 Math.sqrt(n) 就够了,后面都是重复的。

为什么? 因子都是成对出现的。比如 36

  • 2 × 18
  • 3 × 12
  • 4 × 9
  • 6 × 6 (根号 n)
  • 9 × 4 (重复了!)

只要在 6 (根号 n) 之前没找到因子,后面也绝不会有(除非是它自己)。同理 100 的根号是 10,你只要试到 10 就行了,不用傻乎乎试到 99

function isPrime(n) {
  if (n <= 1) return false;
  if (n === 2) return true;      // 2 是质数
  if (n % 2 === 0) return false; // 偶数直接排除

  // 只需要试除奇数,步长为 2
  for (let i = 3; i <= Math.sqrt(n); i += 2) {
    if (n % i === 0) return false;
  }
  return true;
}

3. 判断回文数(不转字符串)

:给你个数字 12321,怎么判断它是回文?不许转成 String。

:这题考的是数字拆解的基本功。

你需要理解 %/ 在十进制里的黄金搭档关系:

  • % 10 是"拿":拿到个位数(剥洋葱的第一层)。
  • / 10 是"扔":扔掉个位数(把洋葱缩小一圈)。

一边拆,一边装: 把 x 的屁股(最后一位)拆下来,装到 reversed 的头上。如果装完发现 reversed === x,那就是回文。

let x = 12321, reversed = 0;
// 假设 x=123
// 第一轮:123 % 10 = 3 (拿3), 123 / 10 = 12 (剩12)
// 第二轮:12 % 10 = 2 (拿2), 12 / 10 = 1 (剩1)
// 第三轮:1 % 10 = 1 (拿1), 1 / 10 = 0 (剩0) -> 结束
while (x > 0) {
  reversed = reversed * 10 + x % 10; // 拼到新数末尾
  x = Math.floor(x / 10);            // 原数去掉末尾
}

4. 负数取余的坑(JS vs 其他语言)

(-1) % 5 在 JS 里等于多少?在 Python 里呢?

:这题特容易踩坑。

  • 在 JS(C/Java)里,结果是 -1。因为它们看重"商"向 0 取整。
  • 在 Python 里,结果是 4。因为 Python 看重"商"向下取整。

实战解法

如果在 JS 做轮播图(点击上一张),算出 -1 程序就崩了。

记住这个万能公式,不管正负都能转正:

const index = (current + step + length) % length;

为什么加 length

因为 % 运算在 JS 里会保留符号。假设当前是第 0 张图(current=0),你要退一张(step=-1),总共5张图(length=5)。

  • 不加 length(0 + (-1)) % 5 = -1 ❌(不仅不对,还越界了)
  • 加 length(0 + (-1) + 5) % 5 = 4 ✅(这就对了,回到了最后一个)
  • 正向移动(0 + 1 + 5) % 5 = 1 ✅(加一圈不影响正数结果,没副作用)

场景举例

  1. 轮播图"上一张"current=0, step=-1(0 - 1 + 5) % 5 = 4 -> 完美跳到最后一张。
  2. 贪吃蛇穿墙:蛇头钻出左边界 x=-1(-1 + width) % width -> 瞬间从右边出来。
  3. 日期计算:今天是周三 3,问 5 天前是周几?(3 - 5 + 7) % 7 = 5 -> 周五。不用脑补倒着数数了。

5. 不用临时变量交换两个数

:给你两个整数 a 和 b,不许用 temp 变量,怎么交换它们?

:除了烂大街的位运算(异或),取余其实也能干这事儿(虽然不如位运算快,但思路很骚)。

思路是把两个数"压缩"到一个大数里,再拆出来。

let a = 123, b = 456;
// 假设 n 足够大,比 a 和 b 都大
const n = 1000;

// 压缩:把 b 藏在高位,a 藏在低位
a = a + b * n; // 123 + 456 * 1000 = 456123

b = a % n;        // 取出低位,也就是原来的 a 
a = Math.floor(a / n); // 取出高位,也就是原来的 b

console.log(a, b); // 456, 123

6. 约瑟夫环问题

场景描述: 有 n 个人围成一圈(编号 0 到 n-1)。从第 0 号开始报数,报到 m 的人出局。下一位继续从 1 开始报数,直到只剩最后一个人。问最后这个人的原始编号是多少?

例子

  • n = 5(5个人:0, 1, 2, 3, 4)
  • m = 3(报到3出局)
  • 出局过程:2号出局 -> 0号出局 -> 4号出局 -> 1号出局 -> 3号幸存
  • 幸存过程:0, 1, 2, 3, 4 -> 0, 1, 3, 4 -> 1, 3, 4 -> 1, 3 -> 3

这道题有点复杂,先上答案,后面咱们掰开揉碎了讲

/**
 * @param {number} n 总人数
 * @param {number} m 报数号码(报到几出局)
 * @return {number} 最后幸存者的编号
 */
function lastRemaining(n, m) {
  let pos = 0; // 时光倒流终点:最后只剩1个人时,幸存者索引是0

  // 开始倒推:从2个人 -> 3个人 -> ... -> n个人
  for (let i = 2; i <= n; i++) {
    pos = (pos + m) % i; // 每一轮人数变多(i),位置都要往后挪 m 位
  }
  return pos;
}

解法思路:时光倒流(坐标偏移)

这个问题如果顺着想(模拟淘汰),数组删元素很麻烦。但如果我们倒着想,利用坐标偏移规律,就非常简单。

1. 正向(淘汰 = 坐标前移): 想象一下,m=3,第 3 个人(索引 2)被淘汰后。

  • 按照规则,下一轮报数从被淘汰者的下一个人(索引 3)开始
  • 这就意味着,索引 3 变成了新一轮的 排头兵(新的索引 0)
  • 相当于所有人整体往前挪了 3 位(注意:不仅仅是填补空缺,而是连起点都变了)。
  • 即:旧索引 - 3 = 新索引

2. 逆向(恢复 = 坐标后移): 我们要找幸存者最初在哪,可以从终局(只剩他 1 人,索引 0)开始,一步步把时光倒流,恢复之前被淘汰的人。

  • 恢复就是淘汰的逆操作
  • 既然淘汰是"往前挪 3 位",那恢复就是"往后挪 3 位"+3)。
  • 公式呼之欲出:新索引 + 3 = 旧索引
  • 核心补丁:因为是圆圈,往后挪超出了队尾就要绕回队头,所以必须 % 上轮人数

推导过程演示(N=5, M=3)

我们只关注最后那个幸存者(假设他叫"天选之子"),他在每一轮的索引是多少?

表头说明

  • n:当前轮剩余人数。
  • 倒推公式(当前索引 + m) % 上轮人数。通过这个公式,我们可以算出幸存者在上一轮(人数更多时)的位置。
轮次 剩余人数 场景描述 计算过程 幸存者索引
终局 1 只剩天选之子 0 (固定) 0
倒数第2轮 2 恢复成2人 (0 + 3) % 2 1
倒数第3轮 3 恢复成3人 (1 + 3) % 3 1
倒数第4轮 4 恢复成4人 (1 + 3) % 4 0
开局 5 恢复成5人 (0 + 3) % 5 3

结论:一开始索引为 3 的那个人,就是天选之子。

💡 核心疑点 Q&A

  1. 为什么要倒推?

    • 正推太麻烦:如果正向模拟,你需要不断地删除数组元素、处理索引越界,数组长度一直在变,计算极其复杂。
    • 终局是已知的:无论过程多复杂,最后一定只剩 1 个人,且那个人的索引一定是 0。从确定的结果出发找源头,比从源头去猜结果要容易得多。
  2. 为什么要恢复上一轮的状态?

    • 这是一个递归/递推的问题。5个人 的游戏淘汰一个,就变成了 4个人 的游戏。
    • 如果我们知道 4个人 里的幸存者是谁,只要把这个幸存者在 4个人 局里的位置,映射(还原)5个人 局里的位置,问题就解决了。
    • 所谓"恢复",其实就是坐标变换
  3. 为什么要 % i(当前人数),而不是 % n(总人数)?

    • 这是很多人的盲点!
    • 每一轮淘汰一个人,圈子的大小都在变
    • 倒数第 2 轮时,圈子只有 2 个人,所以是 % 2;倒数第 3 轮时,圈子有 3 个人,所以是 % 3
    • 我们是在那一轮的圈子里进行坐标恢复,当然要模那一轮的人数
  4. 公式 (当前索引 + m) % 上轮人数 怎么来的?

    • 这就是我们上面提到的坐标偏移
    • + m:代表时光倒流,恢复被删掉的 m 个位置。
    • % 上轮人数:代表在恢复后的圈子里转圈圈,防止索引越界。

💡 小贴士:数学公式版(递归实现)

如果你在算法书上看到这个公式,别慌,它和我们的代码是一回事:

f(n, m) = (f(n-1, m) + m) % n

  • f(n, m):n 个人时幸存者的索引。
  • f(n-1, m):n-1 个人时幸存者的索引(也就是我们代码里的 pos)。
  • 代码里的 for 循环,就是把这个数学递归公式变成了从 2 到 n 的递推

递归版代码(仅供参考)

虽然代码看着短,但如果 n 很大,会爆栈哦。还是推荐用上面的 for 循环(迭代版)。

function lastRemainingRecursive(n, m) {
  if (n === 1) return 0; // 剩下1个人,索引肯定是0
  return (lastRemainingRecursive(n - 1, m) + m) % n;
}

动态规划版(标准 DP)

有了推导公式,自然就能写出 DP。

dp[i] 表示 i 个人时的幸存者索引。

function lastRemainingDP(n, m) {
  let dp = new Array(n + 1);
  dp[1] = 0; // 只有1个人时,索引是0
  for (let i = 2; i <= n; i++) {
    dp[i] = (dp[i - 1] + m) % i; // 状态转移方程
  }
  return dp[n];
}

注:我们最开始写的那个 let pos 的版本,其实就是这个 DP 版本的空间优化版(滚动数组思想),把 dp 数组压缩成了一个变量。

总结

说实话,取余(Modulo)这个概念,以前我也觉得它只是个数学符号,顶多用来算算奇偶数。但当你真的深入去理解它,你会发现它其实是一种化直为曲 的思维方式。

无论是处理时间、轮播图,还是解决像约瑟夫环这样复杂的算法题,取余的核心永远只有两点:控制边界制造循环

希望这篇文章能帮你打破对 % 的固有印象。下次在代码里遇到"溢出"、"循环"或者"映射"的问题时,试着停下来想一想:这里是不是可以用取余来简化一下?

多思考,多动手,编程不仅是写代码,更是对数据规律的优雅掌控。

React - useState 进阶

作者 web_bee
2026年2月11日 12:41

语法:

const [state, setState] = useState(initialState)

参数:

  • setState 它有两种写法

    setState(value)
    
    setState(pre => pre + 1)
    

下面来详细了解一下它为什么会有两种写法。

setState(value)

先看一个例子:

const App = () => {
  const [count, setCount] = useState(0)
  
  const handleClick = () => {
    setCount(count + 1)
    setCount(count + 1)
    setCount(count + 1)
  }
  return <div>
    <h1>{count}</h1>
    <div onClick={handleClick}>点击</div>  
  </div>
}

当点击按钮时,最终页面上展示的会是:1

为什么呢?

  1. state的闭包陷阱(Stale Closure)

    handleClick 函数作用域内的count值,永远是当前渲染时的值,初始值 0

  2. 异步和批处理机制(Async & Batching):

    React 会将 同一个事件处理器 中的所有 setCount 调用批量处理,而不是立即更新

  3. 计算过程:

    • 当前渲染周期中 count = 0
    • 第一次 setCount(count + 1):计划将 count 更新为 1 + 0 = 1
    • 第二次 setCount(count + 1):此时的 count 仍然是 1(不是更新后的 2),所以还是计划更新为 1 + 0 = 1
    • 第三次同理,仍然是计划更新为 1
    • React 合并所有更新,最后只执行一次更新:setCount(count + 1)

因此引入了 函数式写法 setState(pre => pre + 1)

setState(pre => pre + 1)

还是上面的例子:

const App = () => {
  const [count, setCount] = useState(0)
  
  const handleClick = () => {
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
    setCount(prev => prev + 1)
  }
  return <div>
    <h1>{count}</h1>
    <div onClick={handleClick}>点击</div>  
  </div>
}

此时,当点击按钮时,最终页面上展示的会是:3

为什么呢?我们来看一下执行过程

  • 点击
  • 出发函数 handleClick
  • 3个更新函数依次进入队列:setCount(prev => prev + 1) 入队
  • fiber架构整个渲染过程,render阶段执行计算
  • 开始处理 更新队列 (初始值 state = 0)
  • 执行函数一:0 + 1 = 1
  • 执行函数二:此时pre = 1,故 1 + 1 = 2
  • 执行函数三:此时pre = 2,故 2 + 1 = 3
  • 最终 count = 3
  • Commit 阶段:count = 3 被用于渲染,更新到DOM

函数式更新的优势:

  • 不受闭包影响,它可以保证数据更新的准确性
  • 代码更直观

如何选择:

  • 新state 依赖于 旧state,那么我们应该用函数式更新
  • 如果没有依赖关系,我们则使用 直接传值的方式;

批处理

批处理指的是 React 会将多个状态更新合并到一次重新渲染中,而不是每次 setState 都立即触发重新渲染。

原则

  • 事件处理器中的自动批处理

    React 事件处理器(如 onClick、onChange)中,所有的 setState 调用都会被自动批处理。

    理解:同一个事件处理器中 的 setState 会被批处理,而不是单个单个执行

    同一个事件处理器

  • 生命周期和 useEffect 中的批处理

    在生命周期方法和 useEffect 中,更新也会被批处理。

  • React 18 的增强批处理

    React 18 之前:只有在 React 事件处理器中才有自动批处理。

    React 18 及以后:批处理扩展到 所有场景,包括:

    • Promise
    • setTimeout
    • 原生事件处理程序
    • 其他异步代码

Unaipp 使用 wot UI 实现一个带数字键盘的密码输入框弹窗

作者 isixe
2026年2月11日 11:57

最近项目里有个支付输入密码的需求,所以在这之前都是使用一个简单的输入框实现的,但是这样体验不太好。所以,这次就改成了弹窗,尝试达到类似支付宝的弹窗输入密码的形式。

前言

在 Wot UI 中是有密码输入框(wd-password-input)和数字键盘(wd-number-keyboard)两个组件的,但是在文档示例中你会发现,数字键盘是以弹窗的形式覆盖在界面顶层的。如果我们直接使用这个组件,就会出现弹窗盖在弹窗上的奇怪问题。

所以最好的方式,是改写数字键盘组件的全局样式,再将其和密码输入框组合起来,放到新的弹窗中。

防止数字键盘下沉

打开控制台管擦,我们会发现数字键盘实际上也是一个弹窗,而内部会通关组件参数 v-model:visible 进行更新。

因此,首先我们要设置 :hide-on-click-outside="false",防止数字键盘因为点击蒙版意外关闭。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

然后我们会发现一旦点击左下角的键盘按钮,数字键盘就会被收起来,只有点击密码输入框才能弹出。显然这不是我们想要的效果,最终效果应该是数字输入框和密码输入框固定的一直显示。通过观察,弹窗的显示是通过 display 和过渡动画实现的,那么最有效的方式就是样式覆盖了

.pass-keyboard {
  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }
}

我们还需要禁止初始化时,弹窗淡入淡出的动画,防止数字键盘出现延迟显示,闪烁的问题

.pass-keyboard {
  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

到这里,我们就能够让数字键盘固定到界面中,作为一个普通的组件使用了。

在悬浮面板中组合 密码输入框 和 数字键盘

现在,我们把 密码输入框 和 数字键盘同时放进 Wot IU 的底部弹窗组件(wd-popup)中,会发现两个组件没有联动起来,所以还需要配合密码输入框的焦点事件, 让数字键盘一直显示。

...
<wd-password-input
  v-model="payPassword"
  :length="maxLength"
  :gutter="10"
  :mask="true"
  :focused="showKeyboard"
  @focus="handlePasswordFocus"
/>
<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
></wd-keyboard>

...

// 处理密码框聚焦 
function handlePasswordFocus() { 
  // 强制显示键盘
  showKeyboard.value = true; 
}

这样我们就基本完成在不弹出系统输入法的情况下,使用数字虚拟键盘输入框密码的操作了。但是到这里你会发现支付宝的密码弹窗都是自动完成后关闭的,现在我们实现的功能,不能做到自动未完成和关闭弹窗。

不过,我们可以通过自定义数字键盘,增加提交按钮,并监听点击事件实现这个操作。在 @close 我们将关闭动作传递到父组件,让父组件直接关闭最外层的弹窗就可以了。

<wd-keyboard
  class="pass-keyboard"
  :hide-on-click-outside="false"
  v-model:visible="showKeyboard"
  mode="custom"
  :close-text="confirmText"
  @input="onPassInput"
  @close="handlePassClose"
  @delete="onPassDelete"
></wd-keyboard>

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);
}

如果需要自动完成,那么就直接监听密码输入框的输入位数,手动调用上面的关闭事件就可以了

// 监听密码变化
watch(payPassword, (newVal) => {
  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

完整实例

最后,我把这个功能封装成了一个组件,只需要在项目中引用这个组件,并且根据输入完成事件做进一步处理就行了。唯一不足的是,当密码输入错误时,不能像支付宝一样停留在弹窗输入层,只能退其次统一关闭后处理接口请求传参。

<template>
  <view>
    <wd-popup v-model="showPasswordPopup" position="bottom" round :close-on-click-overlay="true">
      <view class="pay-pass-popup">
        <div class="pass-top">
          <view class="popup-title"> {{ title }} </view>

          <!-- 密码长度提示 -->
          <view v-if="showLengthHint" class="password-length-hint">
            {{ payPassword.length }}/{{ maxLength }}
          </view>

          <!-- 密码输入框 -->
          <wd-password-input
            v-model="payPassword"
            :length="maxLength"
            :gutter="10"
            :mask="mask"
            :focused="showKeyboard"
            @focus="handlePasswordFocus"
          />
        </div>

        <wd-keyboard
          class="pass-keyboard"
          :hide-on-click-outside="false"
          v-model:visible="showKeyboard"
          mode="custom"
          :close-text="confirmText"
          @input="onPassInput"
          @close="handlePassClose"
          @delete="onPassDelete"
        ></wd-keyboard>
      </view>
    </wd-popup>
  </view>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";

// 定义Props
interface Props {
  // 弹窗标题
  title?: string;
  // 确认按钮文本
  confirmText?: string;
  // 是否显示弹窗
  visible?: boolean;
  // 密码最大长度
  maxLength?: number;
  // 是否显示密码长度提示
  showLengthHint?: boolean;
  // 是否隐藏密码(显示为圆点)
  mask?: boolean;
  // 是否自动关闭(输入完成后)
  autoConfirm?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  title: "请输入支付密码",
  confirmText: "确定",
  visible: true,
  maxLength: 6,
  showLengthHint: false,
  mask: true,
  autoConfirm: false,
});

// 定义Emits
const emit = defineEmits<{
  "input-complete": [value: string];
}>();

const payPassword = ref<string>("");
const showPasswordPopup = defineModel("visible", { default: false });
// 显示键盘
const showKeyboard = ref<boolean>(true);

// 监听密码变化
watch(payPassword, (newVal) => {
  //   console.log("当前密码:", newVal);

  // 密码输入完成后的处理
  if (newVal.length === props.maxLength) {
    // 如果启用自动关闭
    if (props.autoConfirm) {
      // 延迟关闭,让用户能看到输入完成的效果
      setTimeout(() => {
        handlePassClose();
      }, 300);
    }
  }
});

// 键盘输入处理 - 只接受数字
function onPassInput(val: string) {
  // 只接受数字输入
  if (!/^\d$/.test(val)) {
    return;
  }

  // 如果已经输入到最大长度,不再接受输入
  if (payPassword.value.length >= props.maxLength) {
    return;
  }

  // 添加数字到密码
  payPassword.value += val;
}

// 删除处理
function onPassDelete() {
  if (payPassword.value.length > 0) {
    // 删除最后一位
    payPassword.value = payPassword.value.slice(0, -1);
  }
}

// 处理密码框聚焦
function handlePasswordFocus() {
  // 强制显示键盘
  showKeyboard.value = true;
}

// 处理关闭 - 点击确定按钮后直接关闭弹窗
function handlePassClose() {
  if (payPassword.value.length < 6) return;

  // 触发输入完成事件
  emit("input-complete", payPassword.value);

  // 关闭密码输入弹窗
  //   close();
}

// 清空密码
function clearPassword() {
  payPassword.value = "";
}

// 打开弹窗
function open() {
  clearPassword();
  showPasswordPopup.value = true;
}

// 关闭弹窗
function close() {
  showPasswordPopup.value = false;
  clearPassword();
}

// 获取当前密码
function getPassword(): string {
  return payPassword.value;
}

// 暴露方法给父组件
defineExpose({
  open,
  close,
  clearPassword,
  getPassword,
});
</script>

<style lang="scss" scoped>
.pay-pass-popup {
  justify-content: center;
}

.pass-top {
  background-color: #ffffff;
  padding: 40rpx;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.popup-title {
  font-size: 32rpx;
  font-weight: bold;
  text-align: center;
  color: #333;
}

.password-length-hint {
  font-size: 24rpx;
  text-align: center;
  color: #999;
  margin-top: -10rpx;
}

.pass-keyboard {
  padding: 40rpx 0;
  background-color: #f5f5f5;

  :deep(.wd-popup) {
    position: relative;
    transition: none;
    display: block !important;
  }

  :deep(.wd-key.wd-key--close) {
    background: linear-gradient(37deg, #ff3945 5%, #ff9c4a 80%);
    color: white;
    font-weight: bold;
  }

  :deep(.wd-key) {
    font-size: 32rpx;
    font-weight: 500;
  }

  :deep(.wd-key:active) {
    background-color: #e0e0e0;
  }

  :deep(.wd-key--close:active) {
    background: linear-gradient(37deg, #e6323d 5%, #e68c45 80%);
  }

  :deep(.wd-keyboard__keys) {
    padding: 0 8rpx;
  }

  :deep(.wd-slide-up-enter),
  :deep(.wd-slide-up-leave-to) {
    transform: none;
  }
}

:deep(.wd-password-input__item) {
  width: 45px;
  height: 40px;
  padding: 0;
  background: #f2f2f2;
  border-radius: 10px;
}
</style>

使用示例

<template>
  <ac-pass-popup
    ref="passPopupRef"
    v-model:visible="showPassPopup"
    :title="t('withdrawPage.请输入支付密码')"
    :confirmText="t('withdrawPage.提现')"
    @input-complete="onInputComplete"
  />
</template>

<script setup>
const passPopupRef = ref();

function onRequest(){
    // 接口处理
    ...
    passPopupRef.value.close();
}
</script>

结语

组件库虽然方便了大部分的开发场景,但是在某些情况下,仍然需要自行做类似的功能实现处理。

另外,该组件已经归档到项目 uniapp-vitesse-wot-one

数字输入范围组件vue2

作者 逍遥江湖
2026年2月11日 11:56

image.png

<template>
  <!-- 无内部 el-form 嵌套,适配外部 el-form 校验 -->
  <div class="number-range-input" ref="rangeInputRef">
    <div class="input-wrap" :style="{ width: inputWidth }">
      <!-- 最小值输入框:强制数字输入,绑定内部值 -->
      <el-input
        v-model="innerMin"
        placeholder="最小值"
        @blur="handleSyncValue"
        @input="handleMinInput"
        @clear="handleSyncValue"
        :clearable="clearable"
        type="number"
        style="width: calc(50% - 6px);"
        size="mini"
        :disabled="disabled"
      ></el-input>

      <!-- 视觉分隔符 -->
      <span class="separator">-</span>

      <!-- 最大值输入框:强制数字输入,绑定内部值 -->
      <el-input
        v-model="innerMax"
        placeholder="最大值"
        @blur="handleSyncValue"
        @input="handleMaxInput"
        @clear="handleSyncValue"
        :clearable="clearable"
        type="number"
        style="width: calc(50% - 6px);"
        size="mini"
        :disabled="disabled"
      ></el-input>
    </div>
  </div>
</template>

<script>
export default {
  name: 'NumberRangeInput',
  // Vue2 语法:props 接收外部配置(适配 el-form 校验和回显)
  props: {
    // 双向绑定值:"1,20" 格式字符串(支持回显和 el-form 校验)
    value: {
      type: String,
      default: ''
    },
    // 输入框整体宽度
    inputWidth: {
      type: String,
      default: '300px'
    },
    // 是否显示清除按钮
    clearable: {
      type: Boolean,
      default: true
    },
    // 是否禁用
    disabled: {
      type: Boolean,
      default: false
    },
    // 最大值不能小于最小值的提示文本
    rangeTip: {
      type: String,
      default: '最小值不能大於最大值'
    }
  },
  data() {
    return {
      // 内部缓存值(隔离外部 props,避免直接修改,保留字符串格式)
      innerMin: '',
      innerMax: ''
    };
  },
  watch: {
    // 1. 监听外部 value 变化,实现数据回显(支持 "1,20" 格式回显)
    value: {
      immediate: true,
      handler(val) {
        if (!val || typeof val !== 'string') {
          this.innerMin = '';
          this.innerMax = '';
          return;
        }

        // 分割 "1,20" 格式字符串,解析为最小值和最大值
        const [minStr, maxStr] = val.split('&&').map(item => item.trim());
        const minNum = Number(minStr);
        const maxNum = Number(maxStr);

        // 校验解析结果有效性,有效则填充输入框
        if (!isNaN(minNum) && !isNaN(maxNum) && minStr && maxStr) {
          this.innerMin = String(Math.min(minNum, maxNum));
          this.innerMax = String(Math.max(minNum, maxNum));
        } else {
          this.innerMin = '';
          this.innerMax = '';
        }
      }
    }
  },
  methods: {
    /**
     * 最小值输入监听:联动最大值,确保最小值 ≤ 最大值(保留字符串格式)
     */
    handleMinInput() {
      const { innerMin, innerMax } = this;
      // 转换为数字判断大小,避免字符串比较误差
      if (innerMin && innerMax && !isNaN(Number(innerMin)) && !isNaN(Number(innerMax))) {
        const minNum = Number(innerMin);
        const maxNum = Number(innerMax);
        if (minNum > maxNum) {
          this.innerMax = innerMin;
          this.$message?.warning(this.rangeTip);
        }
      }
    },

    /**
     * 最大值输入监听:联动最小值,确保最大值 ≥ 最小值(保留字符串格式)
     */
    handleMaxInput() {
      const { innerMin, innerMax } = this;
      // 转换为数字判断大小,避免字符串比较误差
      if (innerMin!=='' && innerMax!=='' && !isNaN(Number(innerMin)) && !isNaN(Number(innerMax))) {
        const minNum = Number(innerMin);
        const maxNum = Number(innerMax);
        if (maxNum < minNum) {
          this.innerMin =innerMax;
          this.$message?.warning(this.rangeTip);
        }
      }
    },

    /**
     * 核心:同步内部值到外部,生成 "1,20" 格式字符串(实现双向绑定)
     */
    handleSyncValue() {
      const { innerMin, innerMax } = this;
      console.log("innerMin, innerMax ",innerMin, innerMax )
      console.log("!isNaN(Number(innerMin) ",isNaN(Number(innerMin)))
      console.log("!isNaN(Number(innerMax) ",isNaN(Number(innerMax)))
      let result = '';

      // 校验内部值有效性(均为有效数字且非空)
      if (innerMin!=='' && innerMax!=='' && !isNaN(Number(innerMin)) && !isNaN(Number(innerMax))) {
        const minNum = Number(innerMin);
        const maxNum = Number(innerMax);
        // 生成 "最小值,最大值" 格式字符串,保证顺序正确
        const minStr = String(Math.min(minNum, maxNum));
        const maxStr = String(Math.max(minNum, maxNum));
        result = `${minStr}&&${maxStr}`;
      }else{
        console.log("不滿足")
      }

      // Vue2 双向绑定核心:$emit('input') 同步值到外部 v-model
      this.$emit('input', result);
      // 额外发射事件,方便外部监听详情
      this.$emit('rangeChange', result);
    },

    /**
     * 外部调用:校验方法(适配 el-form 自定义校验,返回是否有效)
     * @returns {Boolean} 校验结果(有效返回 true,无效返回 false)
     */
    validate() {
      const { innerMin, innerMax } = this;
      // 校验规则:两个值均为有效数字且非空
      return innerMin && innerMax && !isNaN(Number(innerMin)) && !isNaN(Number(innerMax));
    },

    /**
     * 外部调用:重置组件(清空输入框,返回空字符串)
     */
    reset() {
      this.innerMin = '';
      this.innerMax = '';
      this.handleSyncValue();
    }
  }
};
</script>

<style scoped>
.number-range-input {
  width: 100%;
  box-sizing: border-box;
}
.input-wrap {
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.separator {
  color: #666;
  user-select: none;
}
::v-deep .el-input__inner{
    height: 28px !important;
    line-height: 28px !important;
    padding: 0 3px !important;
    font-size: 12px !important;
    text-align: center;
}
</style>

CSS自适应屏幕

作者 青屿ovo
2026年2月11日 11:46

CSS自适应屏幕代码教程

✏️ 开篇说明 🎨

本文为全新原创CSS自适应教程,避开复杂理论,聚焦「90%项目必用」的自适应方案,精选4个搜索量TOP的核心知识点,主打极简可复制代码、新手零门槛,每部分都附实战示例+避坑提示,开发时直接抄作业,有错欢迎指出来~

💡 核心目标:学会后能快速实现「PC端+移动端」自适应,适配不同屏幕尺寸(手机、平板、电脑),不用写多套CSS!

📌 四大高频自适应方案(搜索量拉满,刚需实用)

精选知识点(按使用频率排序):1. 媒体查询(最常用,兼容所有浏览器)2. Rem适配(移动端首选)3. Vw/Vh适配(极简无依赖)4. Flex/Grid自适应布局(配合适配方案使用),逐个拆解,代码可直接复制运行。

一、媒体查询(Media Query)—— 自适应万能方案

搜索量TOP1,最基础、最常用的自适应方案,核心逻辑:根据不同屏幕宽度,执行不同的CSS样式,兼容所有浏览器(包括旧版),PC端+移动端通用,新手入门首选。

1. 核心语法(必记)


/* 基础语法:屏幕宽度满足条件时,执行内部样式 */
@media screen and (条件) {
  /* 这里写满足条件后的CSS样式 */
}

/* 常用条件(重点记这3个) */
@media screen and (max-width: 768px) { /* 屏幕宽度 ≤ 768px(移动端) */ }
@media screen and (min-width: 769px) and (max-width: 1200px) { /* 平板端 */ }
@media screen and (min-width: 1201px) { /* 屏幕宽度 ≥ 1201px(PC端) */ }
    

2. 实战示例(可直接复制运行)

需求:实现一个卡片,PC端显示3列、移动端显示1列,字体和内边距随屏幕自适应。


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8"&gt;
  <!-- 关键移动端适配必须加这个meta标签否则媒体查询无效 -->
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>媒体查询实战</title>
  <style>
    /* 通用样式(所有屏幕都生效) */
    .card-container {
      display: flex;
      flex-wrap: wrap; /* 自动换行,配合自适应 */
      gap: 20px; /* 卡片之间的间距 */
      padding: 20px;
    }
    .card {
      background: #f5f5f5;
      border-radius: 8px;
      padding: 16px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }

    /* 1. PC端(≥1201px):3列布局 */
    @media screen and (min-width: 1201px) {
      .card {
        width: calc(33.33% - 20px); /* 3列均分,减去间距 */
        font-size: 16px;
      }
    }

    /* 2. 平板端(769px-1200px):2列布局 */
    @media screen and (min-width: 769px) and (max-width: 1200px) {
      .card {
        width: calc(50% - 20px); /* 2列均分 */
        font-size: 15px;
      }
    }

    /* 3. 移动端(≤768px):1列布局 */
    @media screen and (max-width: 768px) {
      .card {
        width: 100%; /* 占满屏幕宽度 */
        font-size: 14px;
        padding: 12px; /* 减小内边距,适配小屏幕 */
      }
      .card-container {
        padding: 10px; /* 减小容器内边距 */
        gap: 10px;
      }
    }
  </style>
</head>
<body>
  <div class="card-container">
    <div class="card">卡片1(自适应宽度)</div>
    <div class="card">卡片2(自适应宽度)</div>
    <div class="card">卡片3(自适应宽度)</div>
  </div>
</body>
</html>
    

3. 核心避坑(90%新手踩过)

  • 🚨 避坑1:必须添加 viewport meta 标签(示例中已写),否则移动端媒体查询无效,样式错乱。

  • 🚨 避坑2:媒体查询的顺序不能乱!要遵循「从小到大」或「从大到小」,推荐「从大到小」(PC→平板→移动端),避免样式覆盖。

  • 🚨 避坑3:不要在媒体查询中重复写所有样式,只写需要修改的样式(通用样式写在媒体查询外面),减少冗余。

二、Rem适配 —— 移动端首选方案

搜索量TOP2,专门针对移动端的自适应方案,核心逻辑:以根元素(html)的字体大小为基准,所有元素尺寸用rem单位,通过JS动态修改根字体大小,实现「等比例缩放」,适配所有手机屏幕。

1. 核心准备(2步搞定)

步骤1:设置根字体大小(默认基准)

/* CSS 中设置默认根字体大小(PC端/平板端) */
html {
  font-size: 16px; /* 1rem = 16px(默认基准,可自定义) */
}

/* 移动端通过JS动态修改,后面会写 */
    
步骤2:添加JS动态适配代码(核心)

作用:根据手机屏幕宽度,动态修改html的font-size,实现rem等比例缩放(复制到html的head标签内即可)。


<script>
// Rem自适应核心JS(无需修改,直接复制)
function setRemUnit() {
  // 1. 获取屏幕宽度(兼容不同浏览器)
  const screenWidth = document.documentElement.clientWidth || window.innerWidth;
  // 2. 设定基准宽度(通常以375px为手机设计稿基准,可修改)
  const baseWidth = 375;
  // 3. 计算根字体大小(1rem = 屏幕宽度 / 10,方便计算)
  const fontSize = screenWidth / 10;
  // 4. 设置根元素字体大小(最大不超过32px,避免字体过大)
  document.documentElement.style.fontSize = Math.min(fontSize, 32) + 'px';
}

// 5. 初始化执行一次
setRemUnit();
// 6. 屏幕旋转/ resize时,重新执行(适配屏幕变化)
window.addEventListener('resize', setRemUnit);
window.addEventListener('orientationchange', setRemUnit);
</script>
    

2. 实战示例(移动端适配卡片)


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Rem自适应实战</title>
  <script>
    // 先复制Rem自适应JS(核心)
    function setRemUnit() {
      const screenWidth = document.documentElement.clientWidth || window.innerWidth;
      const baseWidth = 375;
      const fontSize = screenWidth / 10;
      document.documentElement.style.fontSize = Math.min(fontSize, 32) + 'px';
    }
    setRemUnit();
    window.addEventListener('resize', setRemUnit);
    window.addEventListener('orientationchange', setRemUnit);
  </script>
  <style>
    /* 所有元素尺寸用rem单位(1rem = 屏幕宽度/10) */
    .card {
      width: 8rem; /* 相当于屏幕宽度的80%(8/10) */
      height: 5rem;
      background: #42b983;
      color: #fff;
      border-radius: 0.5rem; /* 5px(以375px屏幕为例) */
      padding: 0.8rem;
      font-size: 1rem;
      margin: 0 auto;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  </style>
</head>
<body>
  <div class="card">Rem自适应卡片</div>
</body>
</html>
    

3. 核心避坑

  • 🚨 避坑1:JS代码必须放在CSS前面(优先修改根字体大小),否则CSS中的rem计算会出错。

  • 🚨 避坑2:设计稿基准宽度(baseWidth)要和UI设计稿一致(通常375px/750px),否则缩放比例不对。

  • 🚨 避坑3:不要混合使用px和rem(除了根字体大小),否则无法实现等比例自适应。

三、Vw/Vh适配 —— 极简无依赖方案

搜索量TOP3,最简洁的自适应方案,核心逻辑:以「屏幕可视区域」为基准,1vw = 屏幕宽度的1%,1vh = 屏幕高度的1%,无需JS、无需媒体查询,直接用vw/vh单位,适配所有屏幕(推荐简单页面使用)。

1. 核心单位说明(必记)

  • ✅ 1vw = 视口宽度的1%(例:屏幕宽375px,1vw = 3.75px)

  • ✅ 1vh = 视口高度的1%(例:屏幕高667px,1vh = 6.67px)

  • ✅ vmin = vw和vh中的最小值,vmax = vw和vh中的最大值(适配横竖屏)

2. 实战示例(极简自适应页面)

需求:实现一个全屏自适应卡片,居中显示,字体、间距随屏幕缩放。


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vw/Vh自适应实战</title>
  <style>
    /* 重置默认边距 */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    /* 页面全屏 */
    body {
      width: 100vw; /* 占满屏幕宽度 */
      height: 100vh; /* 占满屏幕高度 */
      display: flex;
      align-items: center;
      justify-content: center;
      background: #f8f9fa;
    }

    /* 自适应卡片(所有尺寸用vw) */
    .vw-card {
      width: 80vw; /* 卡片宽 = 屏幕宽的80% */
      height: 50vw; /* 卡片高 = 屏幕宽的50%(避免竖屏过高) */
      background: #6495ed;
      color: #fff;
      border-radius: 2vw; /* 圆角随屏幕缩放 */
      padding: 5vw;
      font-size: 4vw; /* 字体大小 = 屏幕宽的4% */
      text-align: center;
      display: flex;
      align-items: center;
      justify-content: center;
    }
  </style>
</head>
<body>
  <div class="vw-card">Vw/Vh自适应<br/>极简无依赖</div>
</body>
</html>
    

3. 核心避坑

  • 🚨 避坑1:同样需要添加viewport meta标签,否则vw/vh的计算基准会错误(适配移动端)。

  • 🚨 避坑2:vh会受手机状态栏、导航栏影响,高度建议用vw或vmin,避免竖屏时高度溢出。

  • 🚨 避坑3:复杂页面不推荐单独使用vw/vh(会出现字体过大/过小),建议配合媒体查询微调。

四、Flex/Grid自适应布局 —— 配合适配方案使用

搜索量TOP4,布局层面的自适应,核心逻辑:通过Flex(弹性布局)或Grid(网格布局),让元素自动分配空间,配合前面的媒体查询、rem、vw/vh,实现更灵活的自适应,是项目中最常用的组合方案。

1. Flex自适应(最常用,重点记)


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Flex自适应布局</title>
  <style>
    .flex-container {
      display: flex; /* 开启Flex布局 */
      flex-wrap: wrap; /* 自动换行(关键,适配小屏幕) */
      gap: 1rem; /* 间距用rem,配合Rem适配 */
      padding: 1rem;
    }

    .flex-item {
      flex: 1; /* 自动分配剩余空间,实现均分 */
      min-width: 200px; /* 最小宽度,小于这个宽度就换行 */
      height: 100px;
      background: #f08080;
      color: #fff;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 8px;
    }

    /* 移动端微调(配合媒体查询) */
    @media screen and (max-width: 768px) {
      .flex-item {
        min-width: 100%; /* 移动端占满宽度,1列显示 */
      }
    }
  </style>
</head>
<body>
  <div class="flex-container">
    <div class="flex-item">Flex item1</div>
    <div class="flex-item">Flex item2</div>
    <div class="flex-item">Flex item3</div>
  </div>
</body>
</html>
    

2. Grid自适应(复杂布局首选)


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Grid自适应布局</title>
  <style>
    .grid-container {
      display: grid; /* 开启Grid布局 */
      /* 关键:自动填充,每列最小200px,最大1fr(均分) */
      grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
      gap: 1rem;
      padding: 1rem;
    }

    .grid-item {
      height: 100px;
      background: #9370db;
      color: #fff;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 8px;
    }

    /* 移动端微调 */
    @media screen and (max-width: 768px) {
      .grid-container {
        grid-template-columns: 1fr; /* 移动端1列布局 */
      }
    }
  </style>
</head>
<body>
  <div class="grid-container">
    <div class="grid-item">Grid item1</div>
    <div class="grid-item">Grid item2</div>
    <div class="grid-item">Grid item3</div>
    <div class="grid-item">Grid item4</div>
  </div>
</body>
</html>
    

3. 核心避坑

  • 🚨 避坑1:Flex布局中,flex-wrap: wrap 必须写,否则元素不会换行,会溢出屏幕。

  • 🚨 避坑2:Grid布局的grid-template-columns,用auto-fill配合minmax,实现自动适配列数,新手首选。

  • 🚨 避坑3:Flex/Grid只是布局方式,单独使用无法实现“字体、间距”自适应,需配合媒体查询/rem/vw使用。

五、实战组合方案(重点!项目必用)

实际开发中,不会单独使用某一种方案,推荐「组合搭配」,兼顾兼容性和灵活性,示例如下(可直接复制到项目中):


<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>CSS自适应组合方案</title>
  <!-- 1. Rem自适应JS(优先加载) -->
  <script>
    function setRemUnit() {
      const screenWidth = document.documentElement.clientWidth || window.innerWidth;
      const baseWidth = 375;
      const fontSize = screenWidth / 10;
      document.documentElement.style.fontSize = Math.min(fontSize, 32) + 'px';
    }
    setRemUnit();
    window.addEventListener('resize', setRemUnit);
  </script>
  <style>
    /* 2. 通用样式(rem单位) */
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    body {
      font-size: 1rem;
      background: #f5f5f5;
    }

    /* 3. Flex布局(自适应布局) */
    .container {
      display: flex;
      flex-wrap: wrap;
      gap: 1rem;
      padding: 1rem;
    }

    .item {
      flex: 1;
      min-width: 12rem; /* 192px(以375px屏幕为例) */
      height: 8rem;
      background: #42b983;
      color: #fff;
      border-radius: 0.5rem;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    /* 4. 媒体查询(微调细节) */
    @media screen and (max-width: 768px) {
      .container {
        gap: 0.8rem;
        padding: 0.8rem;
      }
      .item {
        min-width: 100%; /* 移动端1列 */
      }
    }

    @media screen and (min-width: 1201px) {
      .container {
        max-width: 1200px; /* PC端限制最大宽度,避免过宽 */
        margin: 0 auto;
      }
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="item">组合方案1</div>
    <div class="item">组合方案2</div>
    <div class="item">组合方案3</div>
  </div>
</body>
</html>
    

六、总结(重点提炼,快速记忆)

  • ✅ 媒体查询:万能方案,兼容所有浏览器,适合PC+移动端,核心是“按屏幕宽度写样式”。

  • ✅ Rem适配:移动端首选,等比例缩放,需配合JS修改根字体大小,适配所有手机。

  • ✅ Vw/Vh适配:极简无依赖,适合简单页面,核心是“以屏幕可视区域为基准”。

  • ✅ Flex/Grid:布局方案,配合前面3种使用,实现元素自动分配空间,灵活适配。

  • 💡 项目首选:Rem + Flex/Grid + 媒体查询(兼顾兼容性、灵活性和开发效率)。

✏️ CSS自适应教程完结,所有代码均可直接复制运行,避坑点已标注清楚~ 收藏起来,开发时直接抄作业,再也不用愁屏幕适配问题!

ES7+ React/Redux/GraphQL/React-Native snippets

作者 搬砖码
2026年2月11日 11:37

ES7+ React/Redux/GraphQL/React-Native snippets 插件深度解析

引言

在前端开发领域,React 凭借其高效的组件化开发模式和丰富的生态系统,成为了众多开发者的首选框架。然而,在日常开发中,我们常常需要编写大量重复的样板代码,这不仅降低了开发效率,还容易引入人为错误。为了解决这一问题,ES7+ React/Redux/GraphQL/React-Native snippets 插件应运而生。这款插件为 VS Code 用户提供了丰富的 React 开发代码模板,能够显著提高开发效率,减少重复劳动。

插件简介

ES7+ React/Redux/GraphQL/React-Native snippets 是一款专为 React 开发者设计的 VS Code 插件,由 dsznajder 开发维护。该插件提供了大量基于 ES7+ 语法的 React、Redux、GraphQL 和 React-Native 代码片段,涵盖了从基础组件创建到高级状态管理的全流程开发需求。截至 2025 年,该插件在 VS Code marketplace 已累计超过 1000 万次下载,评分 4.8/5,被全球 30% 的 React 开发者采用。

核心优势

  1. 高效的代码生成:通过简单的缩写词(trigger)映射到完整代码结构,开发者只需输入几个字母,然后按下 Tab 键,即可快速生成复杂的代码模板。
  2. 全面的框架支持:支持 React、Redux、GraphQL 和 React-Native 等多个框架,能够满足不同项目的开发需求。
  3. 灵活的自定义配置:提供了多个可配置参数,开发者可以根据项目需求调整插件的行为,如是否使用 Prettier 格式化代码、是否在组件顶部自动导入 React 等。
  4. 智能的搜索功能:提供了高效的片段搜索功能,开发者可以通过快捷键或命令面板快速找到需要的代码模板。
  5. 良好的社区活跃度:插件每月更新,及时修复 bug 和添加新功能,确保开发者能够使用到最新的技术和最佳实践。

安装与配置

安装方法

方法一:通过 VS Code Marketplace

  1. 打开 VS Code,按 Ctrl+P(Windows/Linux)或 ⌘P(macOS)打开快速打开面板。
  2. 输入命令:ext install dsznajder.es7-react-js-snippets,然后按 Enter 键安装。

方法二:通过命令行

如果你更喜欢命令行操作,可以使用以下命令:

code --install-extension dsznajder.es7-react-js-snippets

配置选项

从版本 4 开始,插件提供了多个可配置参数,开发者可以根据项目需求调整插件的行为:

选项 描述
languageScopes 定义支持的语言范围,如 ["javascript", "typescriptreact"]
prettierEnabled 是否使用项目 Prettier 配置格式化代码片段,默认为 true
importReactOnTop 是否在组件顶部自动导入 React,React 17+ 用户可设为 false,移除自动导入 React(新 JSX 转换特性),默认为 true
typescript 是否启用 TypeScript 专属代码片段,默认为 true

常用代码片段介绍

React 代码片段

  1. 函数组件:使用 rafce 命令可以快速生成一个无状态的箭头函数组件(Arrow Function Component)。
import React from 'react';

const ComponentName = () => {
  return (
    <div>
    </div>
  );
};

export default ComponentName;
  1. 类组件:使用 rcc 命令可以快速生成一个类组件的模板。
import React, { Component } from 'react';

class ComponentName extends Component {
  render() {
    return (
      <div>
      </div>
    );
  }
}

export default ComponentName;
  1. React Hooks:插件提供了常用的 React Hooks 快捷生成方式,如 useStateuseEffectuseContext 等。
// useState
const [state, setState] = useState(initialState);

// useEffect
useEffect(() => {
  // 执行副作用
  return () => {
    // 清理函数
  };
}, [deps]);

Redux 代码片段

  1. 创建 action:使用 reduxaction 命令可以快速生成 Redux 的 action。
export const actionName = () => {
  return {
    type: 'ACTION_TYPE'
  };
};
  1. 创建 reducer:使用 reduxreducer 命令可以快速创建一个 reducer。
const initialState = {};

export default (state = initialState, action) => {
  switch (action.type) {
    case 'ACTION_TYPE':
      return {
        ...state,
        // ...
      };
    default:
      return state;
  }
};

GraphQL 代码片段

使用 gqlquery 命令可以快速生成 GraphQL 查询的模板。

import gql from 'graphql-tag';

const queryName = gql`
  query {
    field
  }
`;

高级使用技巧

智能搜索功能

插件提供了高效的片段搜索功能,开发者可以通过以下方式快速找到需要的代码模板:

  1. 打开命令面板:Ctrl+Shift+P(Windows/Linux)或 ⌘Shift+P(macOS)。
  2. 输入 ES7 snippet search 并回车。
  3. 输入关键词搜索所需片段。

快捷键配置:默认 Ctrl+Alt+R(Windows/Linux)或 Shift+Cmd+R(macOS)可直接调出搜索面板。

自定义代码片段

插件支持在项目中扩展自定义片段,开发者可以在 .vscode/snippets/javascript.json 中添加项目专属的代码片段。例如,添加一个自定义的函数组件模板:

"Custom Function Component": {
  "prefix": "cfc",
  "body": [
    "import React from 'react';",
    "",
    "const $1 = ({ children }) => {",
    "  return (",
    "    <div className=\"$2\">",
    "      {children}",
    "    </div>",
    "  );",
    "};",
    "",
    "export default $1;"
  ],
  "description": "Custom React Function Component"
}

与其他插件配合使用

ES7+ React/Redux/GraphQL/React-Native snippets 插件可以与其他 VS Code 插件配合使用,进一步提升开发效率:

  1. Prettier:代码格式化工具,确保代码风格一致。
  2. ESLint:代码检查工具,帮助发现和修复代码中的问题。
  3. Auto Import:自动导入依赖的利器,能够自动查找、解析并导入项目中使用的模块。

实战案例:Todo 应用开发全流程

1. 创建功能组件

使用 tsrfce 命令快速创建一个 TypeScript 函数组件。

import React, { useState } from 'react';

type Todo = {
  id: number;
  text: string;
  completed: boolean;
};

const TodoApp = () => {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [inputText, setInputText] = useState('');

  // 添加 Todo 处理函数
  const addTodo = () => {
    if (inputText.trim()) {
      setTodos([...todos, { id: Date.now(), text: inputText, completed: false }]);
      setInputText('');
    }
  };

  return (
    <div className="todo-app">
      <h1>Todo List</h1>
      <div>
        <input
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          placeholder="Add a new todo"
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.text}</li>
        ))}
      </ul>
    </div>
  );
};

export default TodoApp;

2. 添加 Redux 状态管理

使用 tsrcredux 命令快速生成连接 Redux store 的组件。

// TodoList.tsx
import React from 'react';
import { connect } from 'react-redux';
import { addTodo, toggleTodo } from '../store/todos/actions';

type TodoProps = {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
};

export const TodoList = ({ todos, addTodo, toggleTodo }: TodoProps) => {
  // 组件实现...
};

const mapStateToProps = (state) => ({
  todos: state.todos.items
});

const mapDispatchToProps = { addTodo, toggleTodo };

export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

3. 创建 Redux Action

使用 rxaction 命令快速生成 Redux Action。

// store/todos/actions.ts
export const ADD_TODO = 'ADD_TODO';
export const TOGGLE_TODO = 'TOGGLE_TODO';

export const addTodo = (text: string) => ({
  type: ADD_TODO,
  payload: {
    id: Date.now(),
    text,
    completed: false
  }
});

export const toggleTodo = (id: number) => ({
  type: TOGGLE_TODO,
  payload: id
});

插件对比与选择

与同类插件对比

特性 ES7+ React/Redux/GraphQL/React-Native snippets Simple React Snippets Reactjs code snippets
支持框架 React/Redux/GraphQL/React-Native React React
代码片段数量 150+ 80± 100±
自定义选项 5+可配置参数 2-3个 2-3个
社区活跃度 每月更新 季度更新 半年更新
安装体积 <200KB 500KB± 400KB±

选择建议

  • 优先选择 ES7+ React/Redux/GraphQL/React-Native snippets:如果你需要开发复杂的 React 应用,使用 Redux 进行状态管理,或者涉及 React-Native 开发,这款插件是你的不二之选。它提供了全面的代码片段和灵活的配置选项,能够满足不同项目的开发需求。
  • 考虑 Simple React Snippets:如果你只需要基础的 React 功能,避免过多冗余代码片段,或者需要更轻量级的开发环境,可以考虑使用 Simple React Snippets。
  • 混合使用注意事项:如果同时使用多个代码片段插件,可能会出现触发词冲突的情况。此时,你可以通过 VS Code 设置控制插件加载顺序,或者禁用冲突插件。

常见问题与解决方案

问题一:代码片段不生效

原因分析:可能是文件类型未被正确识别,或者语言模式设置不正确。 解决方案

  1. 将当前文件保存为 .jsx.tsx 后缀,避免使用纯 .js/.ts 文件调用 React 专用片段。
  2. 点击 VS Code 窗口右下角的语言模式标识(如 “JavaScript”),手动切换为 JavaScript ReactTypeScript React
  3. 检查键盘布局是否为英文输入法;中文输入法状态下 Tab 键可能被系统拦截,导致无法触发片段。

问题二:触发词冲突

原因分析:其他代码片段类插件(如 Reactjs code snippets)可能与 ES7+ 插件注册相同前缀,导致触发失败或内容错乱。 解决方案

  1. 在扩展视图中搜索已安装的插件,查找名称含 Reactjs code snippetsReact Preview 的条目。
  2. 对疑似冲突插件点击右侧齿轮图标,选择 Disable
  3. 重启 VS Code,再次测试代码片段是否正常展开。

总结

ES7+ React/Redux/GraphQL/React-Native snippets 插件是一款功能强大、高效实用的 React 开发工具,能够显著提高开发效率,减少重复劳动。通过本文的介绍,相信你已经对该插件有了全面的了解,并掌握了其安装、配置和使用方法。在实际开发中,合理使用这款插件,结合其他优秀的开发工具,能够让你的 React 开发之旅更加顺畅。

如果你正在寻找一款能够提升 React 开发效率的插件,不妨试试 ES7+ React/Redux/GraphQL/React-Native snippets,相信它会成为你开发过程中的得力助手。

**注:**大家还有那些好用的插件推荐,欢迎大家来讨论~

Vue3 爆款实用插件教程

作者 青屿ovo
2026年2月11日 11:34

Vue3 爆款实用插件教程(全新版·高频搜索款)

✏️ 开篇说明 🎨

本文为全新原创插件教程,避开所有之前用过的插件,精选「网上搜索量TOP3、90%项目必用」的Vue3爆款插件,主打极简可复制、新手零门槛,每款插件只讲核心用法+避坑,开发时直接抄作业,有错欢迎指出来~

📌 三款高频爆款插件(搜索量拉满,刚需实用)

精选插件:VueUse(万能工具集)、vue-i18n(国际化)、Vant 4(移动端UI组件库),均为Vue3生态搜索量TOP级,覆盖工具、多语言、移动端开发三大高频场景,实用性拉满!

一、VueUse(搜索量TOP1,Vue3万能工具集)

VueUse 是Vue3生态最火的工具集插件,搜索量常年稳居前列,封装了100+常用工具(防抖节流、本地存储、窗口操作、权限判断等),不用重复造轮子,极大提升开发效率,新手必备,一行代码调用,无需复杂配置。

1. 安装VueUse


# npm安装(推荐)
npm install @vueuse/core
# yarn安装
yarn add @vueuse/core

2. 核心用法(4个高频工具,极简示例可复制)

VueUse 无需全局配置,引入即用,以下是开发中最常用的4个工具,覆盖80%使用场景:

(1)useLocalStorage(本地存储,替代localStorage原生写法)

核心优势:自动实现响应式,无需手动JSON.parse/JSON.stringify,刷新页面数据不丢失。


<script setup>
// 引入工具(按需引入,不浪费体积)
import { useLocalStorage } from '@vueuse/core'
import { ref } from 'vue'

// 用法:参数1=存储key,参数2=默认值(自动响应式)
const userInfo = useLocalStorage('userInfo', { name: '测试', age: 20 })
const token = useLocalStorage('token', '')

// 直接修改,自动同步到localStorage(无需手动setItem)
const updateUser = () => {
  userInfo.value.name = '新名字' // 自动同步到localStorage
  token.value = 'abc123456' // 自动同步
}

// 清空存储
const clearStorage = () => {
  userInfo.value = null
  token.value = ''
}
</script>

<template>
  <div>
    <p>用户名:{{ userInfo.name }}</p>
    <button @click="updateUser">修改用户信息</button>
    <button @click="clearStorage">清空存储</button>
  </div>
</template>
(2)useDebounceFn(防抖,解决频繁触发问题)

高频场景:搜索框输入、按钮频繁点击、滚动事件,避免多次触发接口/方法,提升性能。


<script setup>
import { useDebounceFn } from '@vueuse/core'
import { ref } from 'vue'

const inputVal = ref('')

// 防抖方法:参数1=要防抖的函数,参数2=防抖时间(ms)
const search = useDebounceFn((val) => {
  // 模拟搜索接口请求(防抖后,输入停止500ms才触发)
  console.log('搜索关键词:', val)
}, 500)

// 输入框输入时触发防抖方法
const handleInput = (e) => {
  inputVal.value = e.target.value
  search(inputVal.value)
}
</script>

<template>
  <input 
    v-model="inputVal" 
    placeholder="请输入搜索关键词"
    @input="handleInput"
  />
</template>
(3)useThrottleFn(节流,控制方法触发频率)

高频场景:滚动加载、resize事件,控制方法每隔固定时间只能触发一次,区别于防抖(停止输入才触发)。


<script setup>
import { useThrottleFn } from '@vueuse/core'

// 节流方法:每隔1000ms只能触发一次
const handleScroll = useThrottleFn(() => {
  console.log('滚动事件触发(节流):', window.scrollY)
}, 1000)

// 监听滚动事件
window.addEventListener('scroll', handleScroll)

// 组件卸载时移除监听(避免内存泄漏)
onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
})
</script>
(4)useWindowSize(监听窗口大小,适配响应式)

高频场景:响应式布局、适配移动端/PC端,自动监听窗口宽高变化,无需手动写resize事件。


<script setup>
import { useWindowSize } from '@vueuse/core'

// 自动监听窗口宽高,响应式变化
const { width, height } = useWindowSize()

// 可直接判断设备类型(比如移动端宽小于768px)
const isMobile = computed(() => {
  return width.value < 768
})
</script>

<template>
  <div>
    <p>窗口宽度:{{ width }}px</p>
    <p>窗口高度:{{ height }}px</p>
    <p v-if="isMobile">当前为移动端布局</p>
    <p v-else>当前为PC端布局</p>
  </div>
</template>

3. 核心避坑

  • 🚨 避坑1:VueUse 支持按需引入,不要全局引入所有工具(会增加项目体积),用什么引什么。

  • 🚨 避坑2:useLocalStorage 存储复杂数据(对象/数组)时,无需手动序列化,直接赋值即可。

  • 🚨 避坑3:监听类工具(如useWindowSize、useScroll),组件卸载时无需手动清除,VueUse会自动清理,避免内存泄漏。

二、vue-i18n(搜索量TOP2,国际化必备插件)

vue-i18n 是Vue3官方推荐的国际化插件,搜索量常年稳居前列,高频用于多语言项目(中/英/日等),支持文本翻译、日期/数字格式化,配置简单,切换语言无刷新,企业级项目必用。

1. 安装vue-i18n


# npm安装(推荐)
npm install vue-i18n@9
# yarn安装
yarn add vue-i18n@9

⚠️ 注意:Vue3 必须安装 vue-i18n@9+ 版本,低版本不兼容。

2. 全局配置(3步搞定,一次配置全局可用)

步骤1:创建多语言文件(src/lang目录)

在src目录下新建lang文件夹,创建中文(zh-CN.js)、英文(en-US.js)两个语言文件(可扩展其他语言):


// src/lang/zh-CN.js(中文)
export default {
  home: '首页',
  about: '关于我们',
  login: '登录',
  username: '用户名',
  password: '密码',
  submit: '提交',
  switchLang: '切换语言'
}

// src/lang/en-US.js(英文)
export default {
  home: 'Home',
  about: 'About Us',
  login: 'Login',
  username: 'Username',
  password: 'Password',
  submit: 'Submit',
  switchLang: 'Switch Language'
}
步骤2:配置i18n(src/lang/index.js)
// src/lang/index.js
import { createI18n } from 'vue-i18n'
// 引入多语言文件
import zhCN from './zh-CN'
import enUS from './en-US'

// 创建i18n实例
const i18n = createI18n({
  legacy: false, // 必须设为false,适配Vue3的<script setup>语法糖
  locale: 'zh-CN', // 默认语言(中文)
  fallbackLocale: 'en-US', // 备用语言(当默认语言没有对应翻译时,使用英文)
  messages: {
    'zh-CN': zhCN, // 中文语言包
    'en-US': enUS  // 英文语言包
  }
})

export default i18n
步骤3:全局注册(main.js)

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import i18n from './lang' // 引入i18n配置

const app = createApp(App)
app.use(i18n) // 全局注册i18n
app.mount('#app')

3. 组件内使用(极简示例,翻译+切换语言)


<script setup>
// 引入useI18n,获取翻译方法和语言切换方法
import { useI18n } from 'vue-i18n'

// 解构t(翻译方法)、locale(当前语言)、setLocale(切换语言)
const { t, locale, setLocale } = useI18n()

// 切换语言方法(中文 ↔ 英文)
const switchLanguage = () => {
  if (locale.value === 'zh-CN') {
    setLocale('en-US') // 切换为英文
  } else {
    setLocale('zh-CN') // 切换为中文
  }
}
</script>

<template>
  <div class="lang-demo">
    <h1>{{ t('home') }}</h1>
    <div class="login-form">
      <label>{{ t('username') }}:</label>
      <input placeholder="{{ t('username') }}" />
      
      <label>{{ t('password') }}:</label>
      <input type="password" placeholder="{{ t('password') }}" />
      
      <button @click="switchLanguage">{{ t('switchLang') }}</button>
      <button>{{ t('submit') }}</button>
    </div>
  </div>
</template>

4. 核心避坑

  • 🚨 避坑1:必须设置 legacy: false,否则

  • 🚨 避坑2:翻译关键词(如home、username)必须和语言文件中的关键词完全一致,否则无法显示翻译。

  • 🚨 避坑3:切换语言后,页面会自动刷新翻译内容,无需手动刷新页面,复杂数据也会自动适配。

三、Vant 4(搜索量TOP3,移动端UI组件库)

Vant 4 是Vue3生态最火的移动端UI组件库,搜索量稳居移动端UI榜首,由有赞团队开发,封装了60+移动端常用组件(按钮、表单、弹窗、轮播、导航等),适配各种移动端设备,样式美观、可自定义,快速搭建移动端页面,企业级移动端项目首选。

1. 安装Vant 4


# npm安装(推荐)
npm install vant
# yarn安装
yarn add vant

2. 全局配置(新手首选,一次配置全局可用)

// main.js
import { createApp } from 'vue'
import App from './App.vue'
// 1. 引入Vant核心和样式
import Vant from 'vant'
import 'vant/lib/index.css'

const app = createApp(App)
app.use(Vant) // 全局注册Vant,所有组件可直接使用
app.mount('#app')

进阶:按需引入(精简体积),新手可先全局引入,后期优化时再改为按需引入。

3. 组件内使用(3个高频组件,移动端必备)

精选移动端开发最常用的3个组件,示例可直接复制,适配各种移动端场景:

(1)Button(按钮组件,移动端高频)

<template>
  <div class="button-demo">
    <!-- 基础按钮 -->
    <van-button>默认按钮</van-button>
    
    <!-- 不同类型按钮(主色/成功/危险) -->
    <van-button type="primary">主色按钮</van-button>
    <van-button type="success">成功按钮</van-button>
    <van-button type="danger">危险按钮</van-button>
    
    <!-- 禁用状态/加载状态 -->
    <van-button disabled>禁用按钮</van-button>
    <van-button loading>加载中按钮</van-button>
    
    <!-- 自定义大小/颜色 -->
    <van-button size="small">小尺寸按钮</van-button>
    <van-button color="#42b983">自定义颜色按钮</van-button>
  </div>
</template>
(2)Swipe(轮播组件,首页必备)

<template>
  <!-- 轮播组件(自动播放、指示器、点击切换) -->
  <van-swipe class="swipe-demo" autoplay interval="3000" indicator-color="#fff">
    <van-swipe-item>
      <img src="/images/banner1.jpg" alt="轮播图1" class="swipe-img" />
    </van-swipe-item>
    <van-swipe-item>
      <img src="/images/banner2.jpg" alt="轮播图2" class="swipe-img" />
    </van-swipe-item>
    <van-swipe-item>
      <img src="/images/banner3.jpg" alt="轮播图3" class="swipe-img" />
    </van-swipe-item>
  </van-swipe>
</template>

<style scoped>
.swipe-demo {
  height: 180px; /* 移动端轮播图常用高度 */
}
.swipe-img {
  width: 100%;
  height: 100%;
  object-fit: cover; /* 图片自适应,不拉伸 */
}
</style>
(3)Form(表单组件,登录/注册必备)

<script setup>
import { ref } from 'vue'
import { useToast } from 'vant' // 引入Vant的提示组件(配合表单校验)

const toast = useToast()
// 表单数据
const form = ref({
  username: '',
  password: ''
})

// 表单校验+提交
const handleSubmit = () => {
  if (!form.value.username) {
    toast('请输入用户名')
    return
  }
  if (!form.value.password) {
    toast('请输入密码')
    return
  }
  // 校验通过,提交表单(模拟登录接口)
  console.log('表单提交:', form.value)
  toast.success('登录成功')
}
</script>

<template>
  <div class="form-demo">
    <van-form @submit="handleSubmit">
      <van-field
        v-model="form.username"
        label="用户名"
        placeholder="请输入用户名"
        required
      />
      <van-field
        v-model="form.password"
        label="密码"
        type="password"
        placeholder="请输入密码"
        required
      />
      <van-button type="primary" block native-type="submit">登录</van-button>
    </van-form>
  </div>
</template>

4. 核心避坑

  • 🚨 避坑1:Vant 4 仅适配Vue3,Vue2项目需使用Vant 2版本,不要装错版本。

  • 🚨 避坑2:移动端组件需配合viewport配置(public/index.html),否则样式错乱。

  • 🚨 避坑3:表单组件的native-type="submit"不要遗漏,否则表单提交事件无法触发。

<!-- public/index.html 必须添加viewport配置(移动端适配) -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

四、插件总结(重点提炼,快速记忆)

  • ✅ VueUse:万能工具集,搜索量TOP1,防抖节流、本地存储等100+工具,引入即用,不用造轮子。

  • ✅ vue-i18n:国际化必备,搜索量TOP2,多语言切换无刷新,配置简单,企业级项目必用。

  • ✅ Vant 4:移动端UI首选,搜索量TOP3,组件丰富、样式美观,快速搭建移动端页面。

💡 小技巧:这3款插件覆盖Vue3项目90%的高频场景,示例可直接复制到项目中使用,无需复杂修改;后续开发遇到工具、多语言、移动端相关需求,直接查这篇即可!

Vue 3为何无需时间分片?深入解析其高性能渲染机制

作者 大知闲闲i
2026年2月11日 11:25

在当今前端开发领域,性能优化是每个框架都必须面对的核心挑战。React通过引入并发特性和时间分片(Time Slicing)来解决大型应用中的渲染性能问题,但你可能注意到,Vue 3却并未采用类似的时间分片机制。这究竟是为什么呢?本文将深入解析Vue 3的高性能渲染机制,揭示其无需时间分片背后的技术原理。

============================================================================================================================================================

什么是时间分片?

时间分片是一种将渲染工作分割成多个小块的策略,允许浏览器在处理JavaScript渲染任务的同时,能够响应其他任务(如用户输入、动画等)。它通过将长任务分解为可管理的小任务,确保主线程不会被阻塞,从而提供更流畅的用户体验。

React的时间分片机制是其并发模式的核心特性之一,它使得应用能够优先处理高优先级的更新,同时不阻塞用户交互。

Vue 3的优化策略:为何无需时间分片?

1. 革命性的编译器优化

Vue 3引入了全新的编译器架构,在编译阶段就进行了大量优化:

静态提升(Static Hoisting)

在编译过程中,Vue 3能够识别出永远不会改变的静态节点,并将它们提升到渲染函数外部。这意味着这些节点只在首次渲染时计算一次,后续更新完全跳过。

// 编译前的模板
const template = `
  <div>
    <h1>静态标题</h1>
    <p>{{ dynamicContent }}</p>
  </div>
`;

// 编译后的渲染函数(简化示意)
const staticContent = createStaticVNode('<h1>静态标题</h1>');
function render() {
  return createBlock('div', null, [
    staticContent, // 静态节点被提升
    createVNode('p', null, dynamicContent)
  ]);
}

预字符串化(Pre-stringification)

对于包含大量纯静态内容的模板,Vue 3会将其直接转换为字符串,避免了创建大量虚拟DOM节点的开销。

缓存事件处理程序

Vue 3会自动缓存内联事件处理程序,避免在每次重新渲染时创建新的函数实例。

2. 基于Proxy的响应式系统

Vue 3彻底重构了响应式系统,采用ES6 Proxy实现:

// Vue 2基于Object.defineProperty的实现
// 需要递归遍历对象,并对每个属性进行劫持
Object.defineProperty(obj, key, {
  get() { /* 依赖收集 */ },
  set() { /* 触发更新 */ }
});

// Vue 3基于Proxy的实现
// 只需要代理整个对象,按需进行依赖追踪
const proxy = new Proxy(obj, {
  get(target, key) { /* 细粒度依赖收集 */ },
  set(target, key, value) { /* 精确触发更新 */ }
});

新响应式系统的优势:

  • 精确的依赖追踪:只在组件实际使用的属性上建立依赖关系

  • 更好的性能:避免了不必要的依赖收集开销

  • 完整的数据类型支持:可以代理数组、Map、Set等数据结构

3. 智能的虚拟DOM与Diff算法

Vue 3对虚拟DOM进行了深度优化:

块级树(Block Tree)概念

Vue 3引入了"块"的概念,将动态节点分组管理。编译时,它会分析模板中的动态绑定,并创建优化路径:

// 编译后的块结构
const block = {
  dynamicChildren: [/* 仅包含动态节点 */],
  children: [/* 所有子节点 */]
};

在更新时,只需要遍历dynamicChildren,而不是整个虚拟DOM树,大大减少了需要对比的节点数量。

Patch Flag标记系统

每个虚拟DOM节点都包含一个patchFlag,指示了需要更新的类型:

const vnode = {
  type: 'div',
  patchFlag: 8, // 表示只需要更新文本内容
  children: dynamicText
};

这种标记系统允许Vue在更新时直接跳过不需要处理的节点。

4. 高效的异步更新队列

Vue 3的更新机制基于精心设计的异步队列:

// Vue的更新队列机制
let isFlushing = false;
let queue = [];

function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job);
  }
  if (!isFlushing) {
    isFlushing = true;
    Promise.resolve().then(flushJobs);
  }
}

function flushJobs() {
  // 执行队列中的所有更新
  queue.forEach(job => job());
  queue.length = 0;
  isFlushing = false;
}

这种机制确保了:

  • 同一事件循环中的多次数据更新被合并为一次渲染

  • 更新在微任务阶段执行,避免阻塞主线程

  • 自动批处理减少了不必要的DOM操作

5. 组件级细粒度更新

Vue 3的组件系统被设计为细粒度更新:

  • 每个组件都有自己的依赖追踪

  • 只有真正依赖数据变化的组件才会重新渲染

  • 父子组件更新相互独立,避免级联渲染

6. 现代浏览器性能的充分利用

现代浏览器在以下方面有了显著改进:

  • 更快的JavaScript引擎:V8等引擎的优化使JS执行更快

  • 高效的DOM API:现代DOM操作API性能大幅提升

  • 改进的渲染管道:浏览器渲染管道的优化减少了重排重绘的开销

性能对比:实际场景分析

场景一:大型列表渲染

// Vue 3的优化处理
// 使用v-for时,Vue会自动应用虚拟滚动优化策略
<List :items="largeDataset">
  <template #default="{ item }">
    <!-- 只有可见区域的项目被渲染 -->
    <ListItem :item="item" />
  </template>
</List>

场景二:频繁数据更新

// Vue 3的响应式系统处理高频更新
const state = reactive({ count: 0 });

// 即使快速连续更新,Vue也会批量处理
setInterval(() => {
  state.count++;
}, 1); // 每毫秒更新一次
// Vue会将多次更新合并,避免频繁渲染

何时考虑使用时间分片?

尽管Vue 3本身不需要时间分片,但在某些极端场景下,开发者仍然可以手动实现类似的效果:

// 手动实现分片处理大型任务
async function processLargeTask(taskList) {
  const CHUNK_SIZE = 100;
  
  for (let i = 0; i < taskList.length; i += CHUNK_SIZE) {
    const chunk = taskList.slice(i, i + CHUNK_SIZE);
    
    // 处理当前分片
    processChunk(chunk);
    
    // 让出主线程,允许浏览器处理其他任务
    if (i + CHUNK_SIZE < taskList.length) {
      await new Promise(resolve => setTimeout(resolve, 0));
    }
  }
}

Vue 3通过多层次的优化策略,构建了一个高性能的渲染系统:

  1. 编译时优化减少了运行时开销

  2. 高效的响应式系统实现了精确的依赖追踪

  3. 智能的虚拟DOM算法最小化了DOM操作

  4. 异步批处理机制确保了流畅的更新过程

这些优化组合在一起,使得Vue 3在绝大多数应用场景下都能提供出色的性能表现,无需引入复杂的时间分片机制。

然而,值得注意的是,前端技术的发展永无止境。随着Web应用变得越来越复杂,Vue团队也在持续探索新的性能优化技术。Vue 3的设计哲学是:在保持API简洁易用的同时,通过底层优化提供卓越的性能。这种"开发者友好"与"性能卓越"的平衡,正是Vue框架受到广泛欢迎的重要原因。

对于开发者而言,理解这些底层机制不仅有助于编写更高效的Vue应用,还能帮助我们在面对性能挑战时做出更明智的技术选型决策。

Vue3 + Element-Plus 通用的表格合并功能【附源码】

2026年2月11日 11:15

背景

在做后台系统时,表格 合并单元格 几乎是高频需求:

  • 相同 type 的数据要合并
  • 相同 group 连续行要合并
  • 中间还夹着标题行
  • 某些列需要条件合并

很多人一上来就写一堆嵌套循环 + if 判断,最后逻辑混乱、难维护。

今天我给你一个可配置、可复用、强类型、支持特殊行的通用合并方案

一、只需定义规则

const mergeRules: MergedRules<TableItem> = [
  {
    col: 0,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [1, 4];
      }
    },
  },
  {
    col: 1,
    keys: ['type', 'subType'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 2,
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
  },
  {
    col: 3,
    keys: ['type', 'group'],
    getSpan: (row: TableItem) => {
      if (row.title) {
        return [0, 0];
      }
    },
    filter: (row) => row._canAddGroup,
  },
];

规则说明

每条规则控制一列:

字段 作用
col 第几列
keys 哪些字段相同才合并
getSpan 自定义特殊合并规则
filter 条件合并

二、规则类型设计

先看类型定义。

type BaseRule<T> = {
  col: number;
  keys?: (keyof T)[];
  getSpan?: (row: T) => [number, number] | void;
  filter?: (row: T) => boolean | void;
};

export type MergedRules<T> = BaseRule<T>[];

设计亮点

  1. keys 使用 (keyof T)[] —— 强类型字段校验
  2. getSpan 返回 [rowspan, colspan]
  3. filter 支持条件合并
  4. 泛型 T 保证数据结构安全

三、数据结构

export default [
  // 个人
  {
    title: '个人',
    type: 'Individual',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _canAddGroup: true,
    group: '1',
  },
  {
    type: 'Individual',
    subType: 'ID',
    name: '证件号',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  {
    type: 'Individual',
    subType: 'OtherInfo',
    name: '其它信息',
    _addBtn: true,
    _delBtn: true,
    _canAddGroup: true,
    group: '2',
  },
  // 金融机构
  {
    title: '金融机构',
    type: 'FinOrg',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRemitter',
    name: '汇款行',
    _addBtn: true,
    _delBtn: true,
    group: '1',
  },
  {
    type: 'FinOrg',
    subType: 'FinRecevier',
    name: '收款行',
    group: '1',
  },
];

四、核心算法实现

export default function computeMergedRows<T extends object>(
  data: T[],
  rules: MergedRules<T>
) {
  const rowSpanObj: Record<number, Record<number, [number, number]>> = {};

  // 初始化每列状态
  const state: State<T> = rules.map((rule) => ({
    ...rule,
    count: 0,
    start: null,
    prevRow: null,
  }));

  // 初始化 rowSpanObj
  rules.forEach((rule) => {
    rowSpanObj[rule.col] = {};
  });

  data.forEach((currRow, i) => {
    state.forEach((s) => {
      const colStore = rowSpanObj[s.col];
      if (!colStore) return;

      // 1️⃣ 特殊合并规则优先
      const customSpan = s.getSpan?.(currRow);
      if (customSpan) {
        if (s.count > 0 && s.start !== null) {
          colStore[s.start] = [s.count, 1];
        }

        colStore[i] = customSpan;

        // 重置状态
        s.count = 0;
        s.start = null;
        s.prevRow = null;
        return;
      }

      // 2️⃣ 常规合并逻辑
      if (!s.prevRow) {
        s.start = i;
        s.count = 1;
      } else {
        const isSame =
          s.keys && s.keys.length > 0
            ? s.keys.every((k) => currRow[k] === (s.prevRow as T)[k])
            : false;

        const filterPassed = s.filter ? s.filter(currRow) : true;

        if (isSame && filterPassed) {
          colStore[i] = [0, 0];
          s.count++;
        } else {
          if (s.start !== null) {
            colStore[s.start] = [s.count, 1];
          }
          s.start = i;
          s.count = 1;
        }
      }

      s.prevRow = currRow;
    });
  });

  // 3️⃣ 处理最后遗留分组
  state.forEach((s) => {
    if (s.count > 0 && s.start !== null) {
      rowSpanObj[s.col]![s.start] = [s.count, 1];
    }
  });

  return rowSpanObj;
}

五、算法设计思路解析

整个算法核心是:

为每一列维护一个状态机

1️⃣ 每列独立维护状态

type State<T> = Array<
  BaseRule<T> & {
    count: number;
    start: number | null;
    prevRow: T | null;
  }
>;

每一列都会维护:

状态 含义
start 当前合并组起始行
count 当前组行数
prevRow 上一行数据

这使得:

  • 每列逻辑互不影响
  • 可以自由扩展规则
  • 支持不同列不同合并逻辑

2️⃣ 优先处理特殊行

const customSpan = s.getSpan?.(currRow);

为什么要优先处理?

因为像“标题行”这种情况:

  • 它不参与普通比较
  • 它会打断前一组合并
  • 它会强制占据固定 span

所以:

  1. 先结算上一组
  2. 记录当前特殊 span
  3. 重置状态

这就是关键。

3️⃣ 常规合并逻辑

核心判断:

const isSame = s.keys?.every(...)

只要:

  • keys 全部相等
  • filter 条件满足

就:

colStore[i] = [0, 0];

否则:

  • 结算上一组
  • 开始新组

4️⃣ 为什么返回 rowSpanObj 结构?

{
  colIndex: {
    rowIndex: [rowspan, colspan]
  }
}

这种结构刚好可以用于 Element Plus:

const arraySpanMethod = ({ rowIndex, columnIndex }) => {
  return rowSpanObj[columnIndex]?.[rowIndex] ?? [1, 1];
};

六、总结

核心思想其实只有一句话:

用“状态机”去驱动每一列的合并行为。

你不需要在模板里写一堆 if。 也不需要在 span-method 里疯狂判断。

只要定义规则。

用大白话搞懂 Object.assign()

作者 SuperEugene
2026年2月11日 10:21

一、Object.assign() 到底是啥?

Object.assign() 可以把多个对象的属性“合并”到第一个参数指定的目标对象里,并返回这个目标对象。

用人话说就是:把后面对象里的属性,一个个“拷贝”到第一个对象上,类似于“把东西塞进一个目标对象”。

二、基本语法

Object.assign(目标对象, 源对象1, 源对象2, ...)

要点:

  • 第一个参数是目标对象,后面的参数都是源对象
  • 会把源对象上的可枚举自有属性复制到目标对象
  • 返回值就是修改后的目标对象

ps.有不理解枚举是什么的同学可点击此处,一文带你轻松了解枚举。

三、基础示例

示例 1:合并两个对象

const target = { a: 1 };
const source = { b: 2, c: 3 };

Object.assign(target, source);
console.log(target);  // { a: 1, b: 2, c: 3 }

说明:sourcebc 被复制到 targettarget 被修改并返回。

示例 2:合并多个对象

const obj1 = { a: 1 };
const obj2 = { b: 2 };
const obj3 = { c: 3 };

const result = Object.assign(obj1, obj2, obj3);
console.log(result);  // { a: 1, b: 2, c: 3 }
console.log(obj1);    // { a: 1, b: 2, c: 3 } —— obj1 被修改了

说明:从第二个参数开始的对象会依次合并到 obj1obj1 就是目标对象,会被直接改动。

示例 3:相同属性会被覆盖

const target = { a: 1, b: 2 };
const source = { b: 99, c: 3 };  // b 冲突了

Object.assign(target, source);
console.log(target);  // { a: 1, b: 99, c: 3 }

说明:后面的对象会覆盖前面的同名属性,这里是 sourceb: 99 覆盖了原来的 b: 2

四、常见使用场景

场景 1:创建对象副本(浅拷贝)

const original = { name: '小明', age: 18 };
const copy = Object.assign({}, original);

copy.age = 20;
console.log(original.age);  // 18 —— 原对象没变
console.log(copy.age);      // 20

要点:目标对象用空对象 {},这样不会修改原对象,只得到一份浅拷贝。

场景 2:给对象添加默认值

function createUser(options) {
  return Object.assign({}, {
    name: '匿名用户',
    age: 0,
    role: 'guest'
  }, options);  // 用户传入的 options 会覆盖默认值
}

console.log(createUser({ name: '小红' }));
// { name: '小红', age: 0, role: 'guest' }

说明:先用默认对象,再合并用户传入的 options,实现默认值逻辑。

场景 3:复制并修改对象属性

const user = { name: '张三', age: 25 };
const updatedUser = Object.assign({}, user, { age: 26 });

console.log(user);         // { name: '张三', age: 25 }
console.log(updatedUser);  // { name: '张三', age: 26 }

说明:先拷贝 user 到空对象,再覆盖 age,得到新对象,原对象不变。

五、注意事项

1. 浅拷贝,不是深拷贝

const obj1 = { a: 1, nested: { b: 2 } };
const obj2 = Object.assign({}, obj1);

obj2.nested.b = 999;
console.log(obj1.nested.b);  // 999 —— 原对象里的嵌套对象也被改了!

嵌套对象还是同一个引用,修改 obj2.nested 会影响到 obj1.nested。需要深拷贝时要用递归、结构化克隆或 lodash.cloneDeep 等。

补充 - 深浅拷贝定义:

  • 浅拷贝:仅复制对象的表层结构,对嵌套的引用类型仅复制内存引用,新旧对象共享嵌套数据。
  • 深拷贝:完整复制对象的所有层级结构(含所有嵌套引用类型),新旧对象完全独立、无数据共享。

2. 只复制可枚举自有属性

const obj = Object.create({ inherit: '继承属性' });
obj.own = '自有属性';

const result = Object.assign({}, obj);
console.log(result);  // { own: '自有属性' } —— 继承属性不会被复制

说明:只复制源对象自身的可枚举属性,原型链上的属性不会被复制。

3. 会修改目标对象

const target = { a: 1 };
Object.assign(target, { b: 2 });
console.log(target);  // { a: 1, b: 2 } —— target 被改变了

第一个参数会被直接修改,如果不希望影响原对象,可以把第一个参数写成 {}

4. 源参数不是对象时的处理

Object.assign({}, undefined, null, 123, 'abc');
// 忽略 undefined、null、数字,字符串会按字符拆分
// 结果: { 0: 'a', 1: 'b', 2: 'c' }
  • undefinednull 会被跳过
  • 基本类型会先包装成对象,字符串会被当成类数组对象处理,一般不推荐这样用

六、和展开运算符的区别

const obj1 = { a: 1 };
const obj2 = { b: 2 };

// 方式 1:Object.assign
const result1 = Object.assign({}, obj1, obj2);

// 方式 2:展开运算符
const result2 = { ...obj1, ...obj2 };

console.log(result1);  // { a: 1, b: 2 }
console.log(result2);  // { a: 1, b: 2 }

两者都能合并对象,通常用展开运算符 { ...obj1, ...obj2 } 更直观;需要修改已有目标对象、或者处理非对象参数时,再用 Object.assign 更合适。

七、总结

要点 说明
作用 把多个源对象的属性合并到目标对象
第一个参数 目标对象,会被修改
返回值 修改后的目标对象
拷贝方式 浅拷贝
常见用法 对象浅拷贝、添加默认值、合并多个对象

一句话:Object.assign(目标, 源1, 源2, ...) 就是“把后面的对象属性依次塞进第一个对象里”。

以上便是对Object.assign()的分享,欢迎大家指正讨论,与大家共勉。

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

作者 SmalBox
2026年2月11日 10:18

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

高清场景深度节点(HD Scene Depth Node)是Unity高清渲染管线(HDRP)中一个功能强大的着色器图形节点,专门用于访问当前摄像机的深度缓冲区信息。在实时渲染和后期处理效果开发中,深度信息的获取与处理是创建各种视觉特效的基础,而HD Scene Depth节点正是为此目的设计的核心工具。

深度缓冲区存储了场景中每个像素到摄像机的距离信息,这些数据在渲染过程中被广泛用于实现景深效果、雾效、遮挡处理、屏幕空间反射等多种高级渲染技术。通过HD Scene Depth节点,开发者可以直接在着色器图形中采样这些深度值,无需编写复杂的底层着色器代码,大大提高了开发效率和可视化编程的便捷性。

该节点的设计充分考虑了HDRP的高质量渲染需求,支持多种深度采样模式和mipmap级别访问,为创建电影级画质的实时视觉效果提供了强有力的支持。无论是实现精确的深度检测,还是创建基于深度的复杂材质效果,HD Scene Depth节点都是不可或缺的工具。

描述

高清场景深度节点是Unity着色器图形中专门用于访问当前摄像机深度缓冲区的特殊节点。它通过UV输入参数接收标准化的屏幕坐标,并返回对应位置的深度信息。这一机制使得开发者能够在片元着色器阶段精确获取场景中各点的深度数据,为各种基于深度的渲染效果奠定基础。

在渲染管线中,深度缓冲区是一个至关重要的组件,它记录了从摄像机视角看,场景中每个像素对应的最近表面距离。这些深度信息不仅用于确定物体的前后关系(深度测试),还为许多后处理效果和高级渲染技术提供了必要的数据支持。HD Scene Depth节点的核心价值在于它将这些底层数据以直观、易用的方式暴露给着色器图形用户,让非专业图形程序员也能轻松实现复杂的深度相关效果。

该节点的一个关键特性是它只能在片元着色器阶段使用。这是因为深度缓冲区的完整信息只有在几何体渲染完成后才会变得可用,而片元着色器正是处理每个像素最终颜色的阶段。此外,该节点仅适用于非不透明材质,这是因为透明物体通常需要特殊的渲染顺序和混合处理,其深度信息可能与不透明物体有所不同。

Unity预期UV输入值为标准化的屏幕坐标,这意味着坐标范围应该在[0,1]区间内,其中(0,0)通常表示屏幕左下角,(1,1)表示屏幕右上角。这种标准化坐标系统使得深度采样与具体屏幕分辨率无关,增强了着色器的通用性和可移植性。

除了基本的深度采样功能,HD Scene Depth节点还支持访问深度缓冲区的mipmap。Mipmap是预先计算的不同分辨率版本的纹理,用于提高纹理采样的质量和性能。当进行远距离或斜向的深度采样时,使用适当的mip层级可以减少锯齿和闪烁现象,提高视觉效果的质量。Lod(Level of Detail)输入端口正是用于控制采样时使用的mip层级,允许开发者根据具体需求平衡性能与质量。

深度数据的意义与应用

深度数据在实时渲染中具有广泛的应用价值,理解这些数据的含义和潜在用途对于有效使用HD Scene Depth节点至关重要:

  • 空间关系判定:深度值直接反映了像素与摄像机之间的距离关系,可以用于确定物体间的相对位置和遮挡情况
  • 后处理效果基础:许多屏幕空间后处理效果,如景深、雾效、边缘检测等,都高度依赖精确的深度信息
  • 世界位置重建:结合摄像机参数,深度值可以用于重建像素在世界空间中的实际位置,这是许多高级渲染技术的基础
  • 非真实渲染:通过分析深度变化,可以实现轮廓线检测等非真实感渲染效果
  • 特效遮罩:基于深度的阈值判断可以创建各种遮罩效果,用于限制特定区域的特效应用范围

节点内部工作机制

从技术角度看,HD Scene Depth节点在着色器编译过程中会被转换为相应的纹理采样指令,具体来说是对深度缓冲区的采样操作。在HDRP中,深度缓冲区通常以特定格式存储,如R32_FLOAT或R16_FLOAT,具体取决于项目的精度要求和硬件支持。

当在着色器图形中使用该节点时,Unity会根据节点的配置生成相应的HLSL代码。例如,当选择Linear01模式时,生成的代码可能会调用类似Linear01Depth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, uv))的函数,将原始的深度缓冲区值转换为[0,1]范围内的线性深度。

值得注意的是,深度缓冲区的实际内容可能因渲染设置而异。在HDRP中,根据不同的渲染路径和质量设置,深度缓冲区可能包含前向渲染的深度、延迟渲染的G-Buffer深度,或者是特定于某些渲染特性的深度信息。HD Scene Depth节点抽象了这些底层差异,为开发者提供了一致的接口。

渲染管线兼容性

HD Scene Depth节点是专为高清渲染管线(HDRP)设计的专用节点,这意味着它在通用渲染管线(URP)中不可用。这种兼容性差异源于两种渲染管线的架构设计、渲染目标和深度处理机制的根本不同。

高清渲染管线(HDRP)

在高清渲染管线中,HD Scene Depth节点完全受支持并提供了完整的功能集。HDRP作为Unity的高端渲染解决方案,专为需要高端图形保真度的项目设计,如PC、主机游戏和高端移动设备。它采用了复杂的多通道渲染架构和先进的深度管理机制,为HD Scene Depth节点提供了丰富的深度数据访问能力。

在HDRP中,深度缓冲区的管理和使用具有以下特点:

  • 多摄像机支持:HDRP支持多个摄像机并能够正确处理它们之间的深度信息关系
  • 分层渲染:HDRP的渲染层系统允许更精细地控制哪些物体贡献到深度缓冲区
  • 自定义渲染通道:通过自定义渲染通道,开发者可以更灵活地控制深度缓冲区的生成和使用
  • 高质量深度预处理:HDRP包含高级的深度预处理步骤,如反向Z缓冲区、深度压缩等,以提高深度精度和性能

通用渲染管线(URP)

与HDRP不同,通用渲染管线(URP)不支持HD Scene Depth节点。URP作为Unity的轻量级渲染解决方案,优先考虑性能和跨平台兼容性,因此在功能集上相对精简。在URP中,如果需要访问深度信息,通常需要使用不同的方法:

  • Scene Depth Node:URP提供了自己的场景深度节点,但其功能和接口可能与HDRP的版本有所不同
  • Renderer Features:通过自定义渲染器功能,可以在URP中实现类似的深度访问能力
  • Camera Depth Texture:手动启用相机的深度纹理并编写自定义着色器代码进行采样

兼容性决策考量

Unity决定在URP中不提供HD Scene Depth节点是基于多方面的技术考量:

  • 架构差异:HDRP和URP使用不同的渲染架构和缓冲区管理策略,直接移植节点功能并不简单
  • 性能优先级:URP更注重性能和轻量级,某些高级深度功能可能会影响这些目标
  • 使用场景:URP通常用于对图形保真度要求不那么极致的项目,这些项目可能不需要复杂的深度访问功能
  • 资源限制:移动平台等URP常见目标平台可能有纹理格式和采样限制,影响深度缓冲区的实现方式

自定义渲染管线中的行为

对于使用自定义渲染管线的情况,HD Scene Depth节点的行为需要显式定义。如果未在自定义管线中实现相应的功能,该节点将返回默认的白色值(1,1,1),这通常表示缺少有效数据。

在自定义渲染管线中支持HD Scene Depth节点通常涉及以下步骤:

  • 确保渲染管线正确生成并维护深度缓冲区
  • 将深度缓冲区作为全局着色器属性暴露
  • 实现与HDRP兼容的深度解码函数
  • 处理不同平台和渲染设置的深度格式差异

端口

HD Scene Depth节点提供了三个主要端口,用于控制深度采样的参数和输出结果。理解每个端口的功能和正确使用方法对于有效利用该节点至关重要。

UV输入端口

UV输入端口是HD Scene Depth节点最关键的参数之一,它决定了在深度缓冲区中的采样位置。该端口接受Vector 4类型的输入,并与屏幕位置绑定。

技术特性

  • 数据类型:Vector 4(四维向量)
  • 坐标空间:标准化屏幕空间
  • 绑定类型:屏幕位置(自动绑定)
  • 默认值:如未连接,通常使用当前片元的屏幕位置

标准化屏幕坐标

UV输入期望的是标准化屏幕坐标,这意味着无论实际屏幕分辨率如何,坐标范围都应在[0,1]区间内:

  • (0,0) 通常对应屏幕左下角
  • (1,1) 通常对应屏幕右上角
  • Z分量:通常用于透视校正,在大多数情况下可以忽略
  • W分量:通常包含透视除法所需的信息

获取屏幕坐标的方法

在着色器图形中,有多种方式可以获得合适的UV坐标:

  • 使用Screen Position节点获取当前片元的屏幕位置
  • 通过计算自定义UV,实现特定区域的深度采样
  • 使用Tiling And Offset节点调整和变换屏幕坐标

高级应用技巧

  • 视口相对采样:通过偏移UV坐标,可以实现相对于当前像素的深度采样,用于边缘检测等效果
  • 动态UV动画:对UV坐标应用时间相关的变换,可以创建基于深度的动态效果
  • 多重采样:通过在不同UV位置多次采样深度,可以实现更复杂的深度分析效果

Lod输入端口

Lod(Level of Detail)输入端口允许指定采样深度缓冲区时使用的mipmap层级。该功能对于优化性能和改善视觉质量具有重要意义。

技术特性

  • 数据类型:Float(浮点数)
  • 取值范围:通常为0到深度纹理的最大mip层级
  • 默认值:如未连接,通常使用0(最高分辨率)

Mipmap在深度采样中的作用

深度缓冲区的mipmap是通过对原始深度图进行下采样生成的较低分辨率版本:

  • Level 0:原始分辨率,提供最精确的深度信息
  • Level 1:1/2分辨率,在每维度上减半
  • Level 2:1/4分辨率,依此类推
  • 自动mipmap:HDRP通常会自动为深度缓冲区生成mipmap

性能与质量权衡

选择合适的Lod值需要在性能和质量之间取得平衡:

  • 高质量需求:使用低Lod值(接近0),获得更精确的深度信息
  • 性能优化:使用高Lod值,减少纹理采样带宽和缓存压力
  • 远处物体:对屏幕中较小的或远处的物体,可以使用较高Lod值而不会明显影响视觉质量

Lod计算策略

在实际应用中,Lod值可以根据多种因素动态计算:

  • 基于距离:根据像素到摄像机的距离调整Lod
  • 基于屏幕空间导数:使用ddxddy计算适当的Lod值
  • 固定策略:对全屏效果使用统一的Lod值

Output输出端口

Output端口是HD Scene Depth节点的结果输出,它提供了指定屏幕位置的深度信息。根据选择的深度采样模式,输出的具体含义和用途有所不同。

技术特性

  • 数据类型:Vector 3(三维向量)
  • 分量含义:根据深度模式,三个分量可能包含相同或相关的深度信息
  • 数值范围:取决于选择的深度采样模式

输出解释

虽然输出是Vector 3类型,但在大多数情况下,我们主要使用其中一个分量:

  • R通道:通常包含主要的深度信息
  • G和B通道:在某些配置下可能包含辅助信息或保持为0
  • 实际使用:通常通过Swizzle节点提取所需的单个分量

输出稳定性考虑

深度输出值可能受多种因素影响:

  • 深度格式:不同平台可能使用不同的深度缓冲区精度和格式
  • 渲染设置:HDRP的质量设置可能影响深度计算的精度
  • 摄像机参数:近裁剪面和远裁剪面的设置会影响深度值的分布

深度采样模式

HD Scene Depth节点支持多种深度采样模式,每种模式以不同的方式解释和表示深度信息。理解这些模式的差异和适用场景对于正确使用深度数据至关重要。

Linear01模式

Linear01模式将深度值转换为0到1之间的线性表示,这是最常用且直观的深度表示方法。

技术特性

  • 数值范围:[0, 1]
  • 0值含义:位于摄像机的近裁剪面
  • 1值含义:位于摄像机的远裁剪面
  • 分布特性:在近裁剪面和远裁剪面之间线性分布

数学表示

Linear01深度可以通过以下公式计算:

depth_linear01 = (z - near) / (far - near)

其中:

  • z是视图空间中的Z坐标
  • near是近裁剪面距离
  • far是远裁剪面距离

应用场景

Linear01模式因其直观性而被广泛使用:

  • 深度可视化:直接显示Linear01深度可以创建从黑到白的深度图
  • 线性插值:在近远裁剪面之间进行线性混合,如雾效、深度褪色等
  • 阈值处理:基于固定的深度阈值实现效果切换
  • 屏幕空间效果:需要与屏幕空间坐标线性相关的深度应用

使用示例

创建基于深度的雾效:

  1. 使用HD Scene Depth节点采样Linear01深度
  2. 使用SmoothstepRemap节点根据深度计算雾强度
  3. 将雾强度与场景颜色混合

Raw模式

Raw模式提供直接从深度缓冲区读取的原始深度值,这些值通常是非线性的,并且依赖于具体的深度缓冲区格式。

技术特性

  • 数值范围:依赖于深度缓冲区格式,通常是[0, 1]或[1, 0]
  • 分布特性:通常是非线性的,在近处有更高精度
  • 平台依赖性:不同平台和渲染设置可能产生不同的原始深度值

深度缓冲区格式

Raw深度值的具体含义取决于深度缓冲区的内部格式:

  • 反向Z缓冲区:在现代图形API中常见,1.0表示近裁剪面,0.0表示远裁剪面
  • 传统Z缓冲区:0.0表示近裁剪面,1.0表示远裁剪面
  • 浮点深度:使用浮点格式存储,提供更大的范围和精度

应用场景

Raw模式主要用于需要直接处理原始深度数据的高级应用:

  • 深度重建:手动执行深度解码以实现特定的精度需求
  • 深度比较:进行精确的深度相等性或范围测试
  • 自定义深度编码:实现特殊的深度压缩或编码方案
  • 渲染管线开发:在自定义渲染管线中调试和验证深度缓冲区内容

注意事项

使用Raw模式时需要特别小心:

  • 结果可能因平台和渲染设置而异
  • 非线性分布可能导致数值精度问题
  • 需要深入了解特定平台的深度缓冲区行为

Eye模式

Eye模式将深度值转换为视空间中的实际单位距离,提供了最有物理意义的深度表示。

技术特性

  • 数值单位:与世界空间单位一致(通常是米)
  • 数值范围:[near, far],即近裁剪面到远裁剪面的距离
  • 坐标系:视空间坐标系,Z轴指向摄像机前方

数学关系

Eye深度实际上是视空间中的Z坐标:

depth_eye = z

其中z是视图空间中的Z坐标,表示从摄像机位置到片元的直线距离。

应用场景

Eye模式在需要物理准确性的应用中非常有用:

  • 物理精确的效果:如基于真实距离的雾效、光照衰减
  • 世界位置重建:结合屏幕坐标重建像素的世界位置
  • 尺寸感知效果:创建与场景实际尺寸相关的特效
  • 科学可视化:需要精确距离测量的专业应用

性能考虑

Eye模式可能需要额外的计算来从原始深度值转换,但在HDRP中,这种转换通常已经过高度优化。

注意

在使用HD Scene Depth节点时,有几个重要的技术细节和限制需要特别注意,这些因素直接影响节点的行为和使用效果。

使用阶段限制

HD Scene Depth节点只能在片元着色器阶段使用,这是由深度缓冲区的可用性决定的。在着色器图形的其他阶段(如顶点着色器阶段)尝试使用该节点通常会导致编译错误或未定义行为。

技术原因

深度缓冲区在渲染管线的特定点才变得可用:

  • 深度写入阶段:在几何体渲染过程中,深度值被写入深度缓冲区
  • 后处理阶段:在所有不透明几何体渲染完成后,完整的深度缓冲区才可用于采样
  • 片元着色器:作为每个像素处理的最后阶段,自然可以访问已生成的深度信息

变通方案

如果需要在顶点着色器中访问深度信息,可考虑以下替代方案:

  • 在片元着色器中计算所需信息,然后插值到顶点
  • 使用其他方法估算深度,如基于模型空间位置的简单计算
  • 重构渲染流程,将深度相关的计算移至片元着色器

材质类型限制

该节点仅适用于非不透明材质,这意味着它不能在不透明材质的着色器中使用。这一限制与HDRP的渲染顺序和深度管理策略密切相关。

渲染顺序考量

HDRP按照特定顺序渲染物体以优化性能和正确性:

  • 不透明物体:通常从前向后渲染,利用深度测试提前丢弃不可见片元
  • 透明物体:通常从后向前渲染,需要混合且可能修改颜色但不修改深度
  • 深度缓冲区状态:在透明物体渲染时,深度缓冲区已包含所有不透明物体的深度信息

不透明材质中的深度访问

虽然不能直接在不透明材质中使用HD Scene Depth节点,但仍有其他方法可以访问深度信息:

  • 使用Depth Only Pass创建特殊的深度写入通道
  • 通过Renderer Features添加自定义的深度处理逻辑
  • 在后期处理效果中处理深度相关效果

自定义渲染管线集成

在自定义渲染管线中使用HD Scene Depth节点需要显式定义其行为,否则节点将返回白色值(1,1,1)。这一特性使得节点在未正确配置的环境中能够提供可预测的(虽然是错误的)输出。

实现要求

在自定义渲染管线中支持HD Scene Depth节点需要:

  • 深度纹理生成:确保管线正确生成并维护深度纹理
  • 着色器变量绑定:将深度纹理作为全局着色器属性暴露
  • 采样函数实现:提供与HDRP兼容的深度采样函数
  • 平台兼容性处理:处理不同图形API和平台的深度格式差异

集成步骤

将HD Scene Depth节点集成到自定义渲染管线的基本步骤:

  1. 在渲染管线中创建并配置深度纹理
  2. 实现深度纹理的mipmap生成(如果需要Lod功能)
  3. 创建相应的HLSL包含文件,定义深度采样函数
  4. 在着色器图形编译过程中包含这些

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

🔥Proxy 与 Reflect 从入门到实战:ES6 元编程核心特性详解

作者 小马_xiaoen
2026年2月11日 09:41

🔥Proxy 与 Reflect 从入门到实战:ES6 元编程核心特性详解

Reflect.png

🚀一、核心介绍:什么是元编程与 Proxy/Reflect

1. 元编程(Metaprogramming)

元编程是指程序可以对自身代码进行操作、修改和扩展的编程范式,简单来说就是写代码来操作代码。在 JavaScript 中,元编程主要体现在对对象、函数、类的行为进行拦截、修改和增强,而 ES6 引入的 ProxyReflect 正是实现 JavaScript 元编程的核心工具,让开发者可以优雅地拦截对象的底层操作,实现自定义行为。

2. Proxy 是什么?

Proxy(代理)是 ES6 提供的对象拦截器,它可以创建一个目标对象的代理对象,对代理对象的所有底层操作(如属性读取、赋值、删除、函数调用等)进行拦截和自定义处理,而目标对象本身不会被直接修改。

简单理解:Proxy 就像目标对象的**“中间代理人”**,所有对目标对象的操作都必须经过这个代理人,代理人可以决定是否放行、如何修改操作的参数和返回值,甚至拒绝执行操作。

3. Reflect 是什么?

Reflect(反射)是 ES6 提供的内置对象,它将 JavaScript 中对象的底层原生操作(如 Object.getOwnPropertyDescriptorin 运算符、delete 运算符等)封装为统一的方法,放在 Reflect 对象上,同时与 Proxy 的拦截方法一一对应

Reflect 的设计初衷有三个:

  1. 统一原生操作:将分散在 Object、运算符中的原生操作整合,让代码更规范、更易维护;
  2. 配合 Proxy 使用:Reflect 方法的参数、返回值与 Proxy 拦截方法完全匹配,在 Proxy 中使用 Reflect 可以无缝还原目标对象的原生操作
  3. 更合理的返回值:原生操作的返回值不统一(如 delete obj.key 返回布尔值,Object.defineProperty 失败抛错),Reflect 方法统一返回布尔值表示操作成功与否,并通过返回值替代抛错,更易做错误处理。

4. Proxy 与 Reflect 的核心关系

Proxy 负责拦截对象的底层操作,Reflect 负责执行对象的原生底层操作,二者是配套使用、缺一不可的关系:

  • 没有 Reflect,在 Proxy 中还原原生操作需要写大量兼容代码,且易出错;
  • 没有 Proxy,Reflect 仅作为原生操作的封装,失去元编程的核心价值。

简单总结:Proxy 拦截操作,Reflect 执行原生操作,二者结合实现优雅的对象行为自定义。

🎯 二、Proxy 核心特性与基本使用

1. Proxy 基本语法

const proxy = new Proxy(target, handler);
  • target:被代理的目标对象(可以是任意类型的对象:普通对象、数组、函数、甚至另一个 Proxy);
  • handler拦截配置对象,包含多个拦截方法(也叫陷阱方法),每个拦截方法对应一种对目标对象的底层操作,当对代理对象执行该操作时,会触发对应的拦截方法,执行自定义逻辑;
  • proxy:创建的代理对象,后续所有操作都应基于代理对象,而非直接操作目标对象。

核心注意:Proxy 实现的是浅代理,如果目标对象是嵌套对象,嵌套对象的属性操作不会触发顶层 Proxy 的拦截方法,需要手动实现深代理。

2. Proxy 的核心特性

  • 非侵入式拦截:不会修改目标对象本身,所有自定义行为都在代理对象上实现,目标对象保持纯净;
  • 全面的拦截能力:支持 13 种底层操作的拦截,覆盖对象的所有常用操作(属性读写、赋值、删除、函数调用、原型访问等);
  • 代理对象与目标对象解耦:操作代理对象不会直接影响目标对象,可通过 Reflect 在拦截方法中手动执行对目标对象的操作;
  • 无感知使用:代理对象的用法与目标对象完全一致,调用方无需知道代理的存在,降低使用成本。

3. Proxy 常用拦截方法(陷阱方法)

Proxy 的 handler 对象提供了 13 种拦截方法,对应对象的 13 种底层操作,以下是开发中最常用的 8 种,其余方法可参考 ES6 官方文档,使用方式类似。

所有拦截方法的核心设计:参数与对应原生操作匹配,返回值决定操作的最终结果,可通过 Reflect 方法执行原生操作。

拦截方法 对应原生操作 作用
get(target, prop, receiver) obj.prop / obj[prop] 拦截属性读取操作
set(target, prop, value, receiver) obj.prop = value / obj[prop] = value 拦截属性赋值操作
has(target, prop) prop in obj 拦截in 运算符的判断操作
deleteProperty(target, prop) delete obj.prop / delete obj[prop] 拦截delete 运算符的删除操作
getOwnPropertyDescriptor(target, prop) Object.getOwnPropertyDescriptor(obj, prop) 拦截获取属性描述符的操作
apply(target, thisArg, args) func(...args) / func.call(thisArg, ...args) 拦截函数调用操作(仅当 target 是函数时生效)
construct(target, args, newTarget) new Func(...args) 拦截new 运算符的实例化操作(仅当 target 是构造函数时生效)
ownKeys(target) Object.keys(obj) / Object.getOwnPropertyNames(obj) 拦截获取对象自身属性名的操作

📁 4. Proxy 基础使用示例

示例1:拦截普通对象的属性读取和赋值

实现属性不存在时的默认值属性赋值的类型校验,这是 Proxy 最经典的使用场景。

// 目标对象
const user = {
  name: '张三',
  age: 18
};

// 拦截配置对象
const handler = {
  // 拦截属性读取:obj.prop
  get(target, prop, receiver) {
    // 原生操作:Reflect.get(target, prop, receiver)
    // 自定义逻辑:属性不存在时返回默认值'未知'
    return Reflect.get(target, prop, receiver) || '未知';
  },
  // 拦截属性赋值:obj.prop = value
  set(target, prop, value, receiver) {
    // 自定义逻辑:对age属性做类型校验,必须是数字且大于0
    if (prop === 'age' && (typeof value !== 'number' || value <= 0)) {
      throw new Error('年龄必须是大于0的数字');
    }
    // 执行原生赋值操作
    return Reflect.set(target, prop, value, receiver);
  }
};

// 创建代理对象
const proxyUser = new Proxy(user, handler);

// 测试属性读取
console.log(proxyUser.name); // 张三(原生值)
console.log(proxyUser.gender); // 未知(默认值,目标对象无该属性)

// 测试属性赋值
proxyUser.age = 20;
console.log(proxyUser.age); // 20(赋值成功)
proxyUser.gender = '男';
console.log(proxyUser.gender); // 男(赋值成功)

// 测试非法赋值:抛出错误
// proxyUser.age = -5; // Uncaught Error: 年龄必须是大于0的数字
// proxyUser.age = '20'; // Uncaught Error: 年龄必须是大于0的数字

// 目标对象会被同步修改(因为在set中执行了Reflect.set)
console.log(user.age); // 20
示例2:拦截数组的操作,实现数组操作日志

拦截数组的读取赋值push 等操作,记录每一次数组操作的日志,适用于数据监控场景。

// 目标数组
const arr = [1, 2, 3];

// 拦截配置
const handler = {
  get(target, prop, receiver) {
    console.log(`[读取数组] 索引/方法:${prop},当前数组:${target}`);
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, value, receiver) {
    console.log(`[修改数组] 索引:${prop},旧值:${target[prop]},新值:${value}`);
    return Reflect.set(target, prop, value, receiver);
  }
};

// 创建代理数组
const proxyArr = new Proxy(arr, handler);

// 测试数组操作
proxyArr[0] = 10; // [修改数组] 索引:0,旧值:1,新值:10
console.log(proxyArr[0]); // [读取数组] 索引/方法:0,当前数组:10,2,3 → 10
proxyArr.push(4); // 依次触发get(push)、get(length)、set(3,4)、set(length,4)
console.log(proxyArr); // [读取数组] 索引/方法:toString,当前数组:10,2,3,4 → [10,2,3,4]
示例3:拦截函数的调用,实现函数调用日志

当 Proxy 的目标对象是函数时,可通过 apply 方法拦截函数的所有调用方式(直接调用、call、apply),实现通用的函数增强。

// 目标函数
const add = (a, b) => a + b;

// 拦截配置:apply拦截函数调用
const handler = {
  apply(target, thisArg, args) {
    // 自定义逻辑:记录函数调用日志
    console.log(`[函数调用] 函数名:${target.name},this指向:${thisArg},参数:${args}`);
    // 执行原生函数调用
    const result = Reflect.apply(target, thisArg, args);
    // 自定义逻辑:增强返回值
    console.log(`[函数返回] 结果:${result}`);
    return result;
  }
};

// 创建代理函数
const proxyAdd = new Proxy(add, handler);

// 测试函数的各种调用方式,均会触发拦截
console.log(proxyAdd(1, 2)); 
// [函数调用] 函数名:add,this指向:undefined,参数:1,2
// [函数返回] 结果:3 → 3

console.log(proxyAdd.call(null, 3, 4));
// [函数调用] 函数名:add,this指向:null,参数:3,4
// [函数返回] 结果:7 →7

console.log(proxyAdd.apply({}, [5, 6]));
// [函数调用] 函数名:add,this指向:{},参数:5,6
// [函数返回] 结果:11 →11

🚀 三、Reflect 核心特性与基本使用

1. Reflect 核心特性

  • 内置对象:Reflect 是一个内置的静态对象,不能被实例化(类似 Math),所有方法都是静态方法;
  • 原生操作封装:所有方法都是对 JavaScript 底层原生操作的封装,与底层操作行为完全一致;
  • 与 Proxy 一一对应:Reflect 的 13 个方法与 Proxy 的 13 个拦截方法名称完全相同、参数完全一致,是 Proxy 的最佳搭档;
  • 操作结果标准化:所有方法都返回布尔值表示操作是否成功(除了少数获取值的方法如 Reflect.get),失败时不会抛出错误,而是返回 false,更易做错误处理;
  • 绑定 this 更合理:部分原生操作(如 Object.defineProperty)的 this 指向不明确,Reflect 方法的 this 指向由参数明确指定。

2. Reflect 与原生操作的对比

属性赋值属性删除获取属性描述符为例,看 Reflect 如何让原生操作更规范、更易维护:

操作场景 原生操作方式 Reflect 操作方式 优势
属性赋值 obj.prop = value Reflect.set(obj, prop, value) 返回布尔值表示是否成功,支持绑定receiver
属性删除 delete obj.prop Reflect.deleteProperty(obj, prop) 统一返回布尔值,原生操作对不可配置属性返回true(易误导)
获取属性描述符 Object.getOwnPropertyDescriptor(obj, prop) Reflect.getOwnPropertyDescriptor(obj, prop) 对非对象目标,原生操作抛错,Reflect返回false
检查属性是否存在 prop in obj Reflect.has(obj, prop) 统一方法调用,替代运算符,更易封装

示例:Reflect 操作的标准化返回值

const obj = Object.freeze({ name: '张三' }); // 冻结对象,禁止修改属性

// 原生赋值:静默失败,无任何提示
obj.age = 18;
console.log(obj.age); // undefined

// Reflect赋值:返回false,表示操作失败,无抛错
const isSet = Reflect.set(obj, 'age', 18);
console.log(isSet); // false

// 原生删除:对冻结对象的属性删除,返回true(误导)
console.log(delete obj.name); // true
console.log(obj.name); // 张三(实际未删除)

// Reflect删除:返回false,表示操作失败
console.log(Reflect.deleteProperty(obj, 'name')); // false

3. Reflect 常用方法与使用示例

Reflect 共有 13 个静态方法,与 Proxy 的拦截方法一一对应,以下是最常用的 8 种,参数和使用方式与 Proxy 拦截方法完全一致,可直接在 Proxy 中使用。

1. Reflect.get(target, prop, receiver)

读取目标对象的属性值,receiver 为属性访问器(getter)中的 this 指向,可选。

const obj = { name: '张三', get age() { return this._age; }, _age: 18 };
console.log(Reflect.get(obj, 'name')); // 张三
// 绑定getter的this指向
console.log(Reflect.get(obj, 'age', { _age: 20 })); // 20
2. Reflect.set(target, prop, value, receiver)

为目标对象的属性赋值,返回布尔值表示是否成功,receiver 为属性设置器(setter)中的 this 指向,可选。

const obj = { name: '张三' };
console.log(Reflect.set(obj, 'age', 18)); // true
console.log(obj.age); // 18

const freezeObj = Object.freeze({ name: '李四' });
console.log(Reflect.set(freezeObj, 'age', 20)); // false(冻结对象,赋值失败)
3. Reflect.has(target, prop)

检查属性是否存在于目标对象中(包括自身和原型链),等价于 prop in obj,返回布尔值。

const obj = { name: '张三' };
console.log(Reflect.has(obj, 'name')); // true
console.log(Reflect.has(obj, 'toString')); // true(原型链上的属性)
4. Reflect.deleteProperty(target, prop)

删除目标对象的属性,等价于 delete obj.prop,返回布尔值表示是否成功。

const obj = { name: '张三', age: 18 };
console.log(Reflect.deleteProperty(obj, 'age')); // true
console.log(obj.age); // undefined

const freezeObj = Object.freeze({ name: '李四' });
console.log(Reflect.deleteProperty(freezeObj, 'name')); // false(冻结对象,删除失败)
5. Reflect.apply(target, thisArg, args)

调用目标函数,等价于 target.call(thisArg, ...args)target.apply(thisArg, args),返回函数执行结果。

const add = (a, b) => a + b;
console.log(Reflect.apply(add, null, [1, 2])); // 3
console.log(Reflect.apply(Math.max, null, [1, 2, 3])); // 3
6. Reflect.construct(target, args, newTarget)

用 new 运算符实例化构造函数,等价于 new target(...args)newTarget 可选,指定实例的原型,返回实例对象。

class Person {
  constructor(name) {
    this.name = name;
  }
}

const p1 = Reflect.construct(Person, ['张三']);
console.log(p1); // Person { name: '张三' }
console.log(p1 instanceof Person); // true
7. Reflect.getOwnPropertyDescriptor(target, prop)

获取目标对象属性的描述符,等价于 Object.getOwnPropertyDescriptor,返回描述符对象或 undefined,对非对象目标返回 false。

const obj = { name: '张三' };
const desc = Reflect.getOwnPropertyDescriptor(obj, 'name');
console.log(desc); // { value: '张三', writable: true, enumerable: true, configurable: true }
8. Reflect.ownKeys(target)

获取目标对象的所有自身属性名(包括可枚举、不可枚举、Symbol 属性),等价于 Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target)),返回属性名数组。

const obj = { name: '张三' };
Object.defineProperty(obj, 'age', { value: 18, enumerable: false });
const sym = Symbol('gender');
obj[sym] = '男';

console.log(Reflect.ownKeys(obj)); // ['name', 'age', Symbol(gender)]

📁 四、Proxy 与 Reflect 结合实战

Proxy 与 Reflect 的结合是 ES6 元编程的核心,以下是开发中最常见的 3 个实战场景,覆盖数据校验、数据劫持、对象私有化,均为前端框架(如 Vue3)、工具库的核心实现思路。

实战1:实现对象的属性私有化(# 私有属性的替代方案)

ES6 提供了 # 定义私有属性,但存在兼容性问题且无法动态控制,通过 Proxy + Reflect 可实现更灵活的对象属性私有化,指定前缀(如 _)的属性为私有属性,禁止外部读取和修改。

/**
 * 实现对象属性私有化:以_开头的属性为私有属性,禁止外部访问和修改
 * @param {Object} target - 目标对象
 * @returns {Proxy} 代理对象
 */
function createPrivateObj(target) {
  const handler = {
    get(target, prop, receiver) {
      // 自定义逻辑:禁止读取私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止读取`);
      }
      return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
      // 自定义逻辑:禁止修改私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止修改`);
      }
      return Reflect.set(target, prop, value, receiver);
    },
    deleteProperty(target, prop) {
      // 自定义逻辑:禁止删除私有属性
      if (typeof prop === 'string' && prop.startsWith('_')) {
        throw new Error(`属性${prop}是私有属性,禁止删除`);
      }
      return Reflect.deleteProperty(target, prop);
    }
  };
  return new Proxy(target, handler);
}

// 使用示例
const user = createPrivateObj({
  name: '张三',
  _age: 18, // 私有属性
  _gender: '男' // 私有属性
});

// 测试公有属性
console.log(user.name); // 张三
user.name = '李四';
console.log(user.name); // 李四

// 测试私有属性:均抛出错误
// console.log(user._age); // Uncaught Error: 属性_age是私有属性,禁止读取
// user._age = 20; // Uncaught Error: 属性_age是私有属性,禁止修改
// delete user._gender; // Uncaught Error: 属性_gender是私有属性,禁止删除

实战2:实现简易的数据劫持(Vue3 响应式核心思路)

Vue3 的响应式系统核心就是基于 Proxy + Reflect 实现的深度数据劫持,通过拦截对象的属性读取(get)和赋值(set),在读取时收集依赖,在赋值时触发更新。以下实现一个简易版的响应式系统,还原核心思路。

// 依赖映射:存储属性与对应的更新函数
const targetMap = new WeakMap();
// 当前正在执行的更新函数
let activeEffect = null;

/**
 * 注册更新函数(收集依赖)
 * @param {Function} effect - 更新函数
 */
function effect(effectFn) {
  activeEffect = effectFn;
  // 执行一次更新函数,触发属性读取,完成依赖收集
  effectFn();
  activeEffect = null;
}

/**
 * 触发依赖更新
 * @param {Object} target - 目标对象
 * @param {string|Symbol} prop - 属性名
 */
function trigger(target, prop) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const deps = depsMap.get(prop);
  if (!deps) return;
  // 执行所有相关的更新函数
  deps.forEach(fn => fn());
}

/**
 * 收集依赖
 * @param {Object} target - 目标对象
 * @param {string|Symbol} prop - 属性名
 */
function track(target, prop) {
  if (!activeEffect) return;
  // 多层映射:target → prop → effectFn
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let deps = depsMap.get(prop);
  if (!deps) depsMap.set(prop, (deps = new Set()));
  deps.add(activeEffect);
}

/**
 * 创建响应式对象(深度代理)
 * @param {Object} target - 目标对象
 * @returns {Proxy} 代理对象
 */
function reactive(target) {
  const handler = {
    get(target, prop, receiver) {
      const result = Reflect.get(target, prop, receiver);
      // 收集依赖
      track(target, prop);
      // 深度代理:如果返回值是对象,继续创建响应式对象
      return typeof result === 'object' && result !== null ? reactive(result) : result;
    },
    set(target, prop, value, receiver) {
      const oldValue = Reflect.get(target, prop, receiver);
      const isSet = Reflect.set(target, prop, value, receiver);
      // 只有值发生变化时,才触发更新
      if (isSet && oldValue !== value) {
        trigger(target, prop);
      }
      return isSet;
    }
  };
  return new Proxy(target, handler);
}

// 使用示例:模拟Vue3的响应式
const state = reactive({
  name: '张三',
  age: 18
});

// 注册更新函数:当state的属性变化时,自动执行
effect(() => {
  console.log(`姓名:${state.name},年龄:${state.age}`);
});

// 修改属性,触发更新函数执行
state.name = '李四'; // 输出:姓名:李四,年龄:18
state.age = 20; // 输出:姓名:李四,年龄:20
state.age = 20; // 值未变化,不触发更新

实战3:实现函数的参数校验与返回值格式化

通过 Proxy 拦截函数的调用(apply),结合 Reflect 执行原生调用,实现通用的函数参数校验和返回值格式化,无需修改函数本身,实现非侵入式增强。

/**
 * 函数增强:参数校验 + 返回值格式化
 * @param {Function} fn - 目标函数
 * @param {Array} validateRules - 参数校验规则(每个元素为对应参数的校验函数)
 * @param {Function} formatFn - 返回值格式化函数
 * @returns {Proxy} 代理函数
 */
function enhanceFn(fn, validateRules = [], formatFn = v => v) {
  const handler = {
    apply(target, thisArg, args) {
      // 1. 参数校验:校验规则数量与参数数量一致
      if (args.length !== validateRules.length) {
        throw new Error(`参数数量不匹配,期望${validateRules.length}个,实际${args.length}个`);
      }
      // 逐个校验参数
      args.forEach((arg, index) => {
        const isValidate = validateRules[index](arg);
        if (!isValidate) {
          throw new Error(`第${index+1}个参数${arg}校验失败`);
        }
      });
      // 2. 执行原生函数调用
      const result = Reflect.apply(target, thisArg, args);
      // 3. 返回值格式化
      const formatResult = formatFn(result);
      return formatResult;
    }
  };
  return new Proxy(fn, handler);
}

// 目标函数:计算两个数的和
const add = (a, b) => a + b;

// 增强函数:参数必须是数字,返回值格式化为{ result: 结果 }
const proxyAdd = enhanceFn(
  add,
  [
    (v) => typeof v === 'number', // 第一个参数必须是数字
    (v) => typeof v === 'number'  // 第二个参数必须是数字
  ],
  (res) => ({ result: res })
);

// 测试合法调用
console.log(proxyAdd(1, 2)); // { result: 3 }

// 测试非法调用:参数校验失败
// console.log(proxyAdd(1, '2')); // Uncaught Error: 第2个参数2校验失败
// console.log(proxyAdd(1)); // Uncaught Error: 参数数量不匹配,期望2个,实际1个

🚨 五、Proxy 与 Reflect 的常见坑与避坑指南

1. Proxy 是浅代理,嵌套对象需手动实现深代理

问题:Proxy 默认只代理目标对象的顶层属性,嵌套对象的属性操作不会触发拦截,如 proxyObj.obj.prop = value 不会触发 proxyObj 的 set 拦截。 解决:在 get 拦截方法中,判断返回值是否为对象,若是则递归创建 Proxy 代理,实现深代理(如实战2中的 reactive 函数)。

2. Reflect.set/deleteProperty 对不可变对象返回 false,而非抛错

问题:对冻结(Object.freeze)、密封(Object.seal)的对象执行赋值/删除操作,Reflect 方法返回 false,而原生操作会静默失败,若未判断返回值,易导致逻辑错误。 解决:在 Proxy 的 set/deleteProperty 拦截方法中,必须判断 Reflect 方法的返回值,根据返回值做错误处理或提示。

3. 代理对象与目标对象的原型链不一致

问题:Proxy 会创建一个新的对象,其原型链与目标对象不同,使用 instanceof 时可能出现不符合预期的结果(但实际开发中影响极小,因为代理对象的用法与目标对象一致)。 解决:若需严格保证原型链一致,可通过 Object.setPrototypeOf(proxy, Object.getPrototypeOf(target)) 手动设置代理对象的原型。

4. 拦截 ownKeys 时,返回的属性名必须符合规范

问题:在 ownKeys 拦截方法中,若返回的属性名包含非字符串、非Symbol的值,或包含重复的属性名,会抛出错误。 解决:确保 ownKeys 拦截方法返回的是由字符串和Symbol组成的无重复数组,建议基于 Reflect.ownKeys(target) 做过滤,而非手动创建。

5. Reflect.construct 的 newTarget 参数易被忽略

问题:使用 Reflect.construct 时,若未指定 newTarget,实例的原型是 target.prototype;若指定了 newTarget,实例的原型是 newTarget.prototype,易因忽略该参数导致原型链错误。 解决:明确 Reflect.construct 的第三个参数的作用,根据业务需求决定是否指定,避免原型链混乱。

🔧 六、Proxy/Reflect 的兼容性与替代方案

1. 兼容性

Proxy 和 Reflect 均为 ES6 特性,兼容性如下:

  • 桌面端:Chrome ≥ 49、Firefox ≥ 18、Edge ≥ 12、Safari ≥ 10;
  • 移动端:微信小程序/公众号、App 内置 Chromium 浏览器均支持,iOS Safari ≥ 10、Android WebView ≥ 49;
  • 不支持:IE 浏览器完全不支持,无原生兼容方案。

2. 替代方案

若需兼容 IE 等低版本浏览器,可使用以下替代方案:

  • 数据劫持:使用 Object.defineProperty 替代 Proxy(Vue2 的响应式方案),但仅能拦截属性的读取和赋值,无法拦截数组的 push/pop 等方法,也无法实现深代理的自动检测;
  • 原生操作封装:使用自定义工具函数封装原生操作,替代 Reflect,但需手动处理各种边界情况,代码量较大。

核心结论:现代前端开发(如 Vue3、React 生态)均已放弃 IE 兼容,可放心使用 Proxy + Reflect,这是未来 JavaScript 元编程的主流方案。

🏠七、Proxy/Reflect 的适用场景

Proxy + Reflect 作为 ES6 元编程的核心,适用于需要对对象/函数的底层行为进行拦截、增强和自定义的场景,是前端框架、工具库、通用组件的核心实现技术,典型场景包括:

  1. 响应式系统:如 Vue3 的 reactive、React 的 useState 底层实现,通过数据劫持实现视图与数据的自动同步;
  2. 数据校验:如表单验证、接口参数校验,对对象的属性赋值进行拦截,实现实时校验;
  3. 对象私有化:实现灵活的私有属性,替代 ES6 的 # 私有属性,支持动态控制;
  4. 函数增强:如日志记录、性能监控、参数校验、返回值格式化,非侵入式增强函数功能;
  5. 数据监控:如埋点系统、数据统计,拦截对象的所有操作,记录操作日志;
  6. 模拟实现:如模拟 Map、Set、Promise 等内置对象,自定义其行为;
  7. AOP 面向切面编程:在不修改原有代码的前提下,为函数添加前置/后置通知,实现日志、事务、缓存等功能。

📌八、总结

  1. Proxy 是对象拦截器,创建目标对象的代理对象,拦截 13 种底层操作,实现自定义行为,非侵入式修改对象行为,是 JavaScript 元编程的核心;
  2. Reflect 是原生操作封装器,将分散的原生操作整合为统一的静态方法,与 Proxy 一一对应,返回值标准化,是 Proxy 的最佳搭档;
  3. Proxy 与 Reflect 必须结合使用:Proxy 拦截操作,Reflect 执行原生操作,避免手动实现原生操作的兼容代码,让代码更优雅、更易维护;
  4. Proxy 实现深代理:需在 get 拦截方法中递归创建 Proxy,对嵌套对象进行代理,这是 Vue3 响应式的核心思路;
  5. 核心特性:Proxy 浅代理、状态不可逆、代理对象与目标对象解耦;Reflect 静态对象、标准化返回值、与 Proxy 参数匹配;
  6. 适用场景:响应式系统、数据校验、对象私有化、函数增强、数据监控等,是前端框架、工具库的必备技术;
  7. 兼容性:支持所有现代浏览器,IE 完全不支持,现代前端开发可放心使用,无需兼容;
  8. 核心价值:Proxy + Reflect 让 JavaScript 拥有了优雅的元编程能力,开发者可以通过写代码来操作代码,大幅提升代码的灵活性、可扩展性和可维护性。

Proxy 与 Reflect 是 ES6 中最强大的特性之一,也是前端进阶的必备知识点,掌握二者的结合使用,不仅能理解 Vue3 等现代框架的底层实现,更能在实际开发中写出更优雅、更灵活的代码,实现各种复杂的业务需求。

Pinia 超进化!从此不需要 Axios

作者 Forever7_
2026年2月11日 09:41

Pinia Colada 让 Vue 应用中的数据请求变得轻而易举。它构建于 Pinia 之上,彻底消除了数据请求带来的所有复杂度与样板代码。它具备完整的类型支持、可摇树优化,并且遵循与 Pinia 和 Vue 一致的设计理念:简单易上手、灵活可扩展、功能强大,还能实现渐进式接入。

640.png

核心特性

  • ⚡️ 自动缓存:智能客户端缓存,自带请求去重能力
  • 🗄️ 异步状态:简化异步状态管理逻辑
  • 🔌 插件系统:功能强大的插件扩展体系
  • ✨ 乐观更新:服务端响应返回前即可更新 UI
  • 💡 合理默认配置:开箱即用,同时保持全量可配置性
  • 🧩 内置插件:自动重新请求、加载延迟等功能一键启用
  • 📚 类型脚本支持:业界领先的 TypeScript 类型体验
    • 💨 极小包体积:基础核心仅约 2kb,且完全支持摇树优化
  • 📦 零外部依赖:除 Pinia 外无任何第三方依赖
  • ⚙️ 服务端渲染(SSR):原生支持服务端渲染

📝 注意:Pinia Colada 始终致力于持续改进和演进。我们非常欢迎大家针对现有功能或新功能方向提供反馈!同时也高度赞赏对文档、Issue、PR(代码合并请求)的贡献。

安装

npm install pinia @pinia/colada

安装你所需功能对应的插件:

import { createPinia } from 'pinia'  
import { PiniaColadafrom '@pinia/colada'  
  
app.use(createPinia())  
// 需在 Pinia 之后安装  
app.use(PiniaColada, {  
  // 可选配置项  
})

使用方式

Pinia Colada 的核心是 useQuery 和 useMutation 两个函数,分别用于数据查询和数据写入。以下是简单示例:

<script lang="ts" setup>  
import { useRoute } from 'vue-router'  
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'  
import { patchContact, getContactById } from '~/api/contacts'  
  
const route = useRoute()  
const queryCache = useQueryCache()  
  
// 数据查询  
const { data: contact, isPending } = useQuery({  
  // 缓存中该查询的唯一标识  
  key: () => ['contacts', route.params.id],  
  // 实际执行的查询逻辑  
  query: () => getContactById(route.params.id),  
})  
  
// 数据变更  
const { mutate: updateContact, isLoading } = useMutation({  
  // 实际执行的变更逻辑  
  mutation: patchContact,  
  async onSettled({ id }) {  
    // 使上述查询失效,触发数据重新请求  
    await queryCache.invalidateQueries({ key: ['contacts', id], exact: true })  
  },  
})  
</script>  
  
<template>  
  <section>  
    <p v-if="isPending">加载中...</p>  
    <ContactCard  
      v-else  
      :key="contact.id"  
      :contact="contact"  
      :is-updating="isLoading"  
      @update:contact="updateContact"  
    />  
  </section>  
</template>

想了解更多核心概念及使用方式,请查阅官方文档。 pinia-colada.esm.dev/

研发场景十大热门 Skills 推荐

作者 阿虎儿
2026年2月11日 09:38

本文面向实际研发场景,整理并推荐了一组可直接应用于日常开发流程的 Agent Skills。这些 Skills 覆盖前端设计、前后端开发、代码审查、自动化测试、CI/CD、问题修复以及文档维护等常见环节,分别针对具体任务提供明确的能力边界与使用场景说明,帮助你在不同阶段选择合适的 Skills,提高开发效率。

图片

前端设计

名称

frontend-design

作者

Anthropic

地址

   

github.com/anthropics/…

   

简介

该 Skill 旨在创建具有 独特性 和 高设计品质 的前端界面,能够达到 生产级别 (production-grade) 的标准。它的核心目标是避免生成千篇一律、缺乏独特风格的 “AI 风格” 界面,而是通过在设计上有意地选择大胆、明确的美学方向(例如:极简、复古、未来感、野兽派等),并注重排版、色彩、动效、空间布局等细节,来打造出令人印象深刻、具有艺术感的前端页面。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 构建网页组件或页面

    当你需要从零开始创建一个具体的 UI 元素时,例如一个 React 组件、一个 HTML/CSS 布局,或一个独立的静态页面。该 Skill 会确保这个组件不仅功能完善,而且在视觉上具有辨识度。

  • 开发完整的 Web 应用或网站

    当你需要构建一个完整的应用界面时,比如一个产品的 Landing Page、一个数据仪表盘或一个小型网站。该 Skill 会从整体出发,确立一个统一且鲜明的设计风格,并将其贯彻到应用的每一个角落。

  • 美化或重塑现有界面

    当你有一个已经存在的、但设计平庸的网页或应用,并希望提升其视觉品质时。该 Skill 会专注于美化层面的工作,通过引入独特的字体、创意的色彩方案、精致的动效和新颖的布局,来重塑界面的整体美感。

图片

前端开发

名称

cache-components

作者

vercel

地址

   

github.com/vercel/next…

   

简介

该 Skill 旨在将 Next.js 的 Partial Prerendering (PPR) 和缓存组件(Cache Components)的最佳实践,通过 AI 助手无缝集成到开发工作流中。当项目环境启用 cacheComponents: true 配置时,该 Skill 将被激活,为你提供自动化的代码生成与优化能力。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • PATTERNS.md: 一份详细的说明文档,通过丰富的代码示例和场景解释,来展示如何高效、正确地使用 Cache Components。

  • REFERENCE.md: Cache Components 的官方 API 参考手册,它精确定义了所有相关函数、指令和核心概念,用于查找具体技术细节。

  • TROUBLESHOOTING.md: Cache Components 的故障排查指南,提供了一系列常见错误的解决方案、调试清单和实用技巧,用于诊断并解决使用缓存组件时遇到的问题。

应用场景

  • 自动生成缓存优化的数据组件

    当创建数据获取组件时,系统会自动应用最优的渲染策略:针对可共享数据(如产品目录),使用 **'use cache' **语法进行缓存;针对用户专属内容,则自动添加  ** ** 边界,以实现动态流式渲染。

  • 自动实现数据变更后的缓存失效

    当生成用于修改数据的 Server Action 时,系统会自动注入缓存失效逻辑(如 **updateTag() **方法)。这能确保数据变更后,相关缓存会立即更新,从而保障整个应用的数据一致性。

  • 智能化页面构建与代码现代化

    在构建页面或审查代码时,系统会强制遵循 Partial Prerendering(PPR)架构规范,以实现最优的加载性能。同时,它能识别并给出现代化改造建议,例如用组件级的  **'use cache' **替代已过时的页面级缓存配置。

图片

全栈开发

名称

fullstack-developer

作者

Shubhamsaboo

地址

   

github.com/Shubhamsabo…

   

简介

该 Skill 的主要作用是扮演一个 精通现代 Web 开发技术的全栈专家角色。它专注于使用 JavaScript/TypeScript 技术栈,特别是 React (Next.js)、Node.js 和主流数据库,来帮助你完成各类 Web 开发任务。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 构建完整的 Web 应用: 从前端到后端,提供完整的解决方案。

  • 开发 API: 创建 RESTful 或 GraphQL 风格的后端接口。

  • 创建前端界面: 使用 React 或 Next.js 构建现代化的用户界面。

  • 数据库和数据建模: 设计和设置如 PostgreSQL 或 MongoDB 等数据库。

  • 实现用户认证与授权: 集成 JWT、OAuth 等认证机制。

  • 部署与扩展应用: 提供在 Vercel、Netlify 等平台上的部署指导。

  • 集成第三方服务: 在应用中接入外部服务。

图片

代码审查(前端)

名称

frontend-code-review

作者

langgenius

地址

   

github.com/langgenius/…

   

简介

该 Skill 的核心功能是自动化审查前端代码(尤其针对 .tsx、.ts、.js 等文件)。它会依据预定义的规则清单,从代码质量、性能表现、业务逻辑等维度对代码开展全面分析。审查完成后,系统将生成结构清晰的报告,把发现的问题划分为 “紧急待修复” 和 “改进建议” 两类,并标注具体的代码位置、提供可落地的修复方案,助力你提升代码质量。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • references/business-logic.md: 定义与特定业务场景相关的规则,以防止出现逻辑错误或 Bug。例如 : 规定在某些组件中不能使用 workflowStore ,因为在特定使用场景下(如从模板创建管道时)它会导致页面白屏。

  • references/code-quality.md: 包含一系列通用编码规范,旨在保持代码的整洁、一致和可维护性。例如 : 推荐使用 cn 这样的工具函数来处理动态的 CSS 类名,而不是手动拼接字符串,以保证代码风格统一。

  • references/performance.md: 专注于前端性能,提供避免常见性能问题的最佳实践。例如 : 建议将传递给子组件的对象或数组等复杂属性用 useMemo 包裹起来,以防止不必要的组件重渲染。

应用场景

  • 审查待提交的变更

    在你准备提交代码(git commit)前,可使用该 Skill 审查所有已修改或已暂存的文件,提前识别并标记不符合规范的代码,避免潜在的 Bug 或性能问题被合入代码库。

  • 审查指定的文件

    当你需对特定文件或模块进行重构、优化或问题排查时,可将相关文件交由该 Skill 审查,从而对目标文件开展针对性深度分析,快速获取该文件的质量评估结果及改进建议。

  • 获取结构化的修复报告

    代码审查发现问题后,该 Skill 会自动整理并输出标准化报告。这份报告不仅罗列问题,还会按紧急程度排序,同时标注问题对应的文件路径、行号、相关代码片段,并给出可执行的修复方案。

图片

代码审查(通用)

名称

code-reviewer

作者

google-gemini

地址

github.com/google-gemi…

   

简介

该 Skill 旨在引导 AI 开展专业且全面的代码审查工作。它既支持审查本地代码改动(包括已暂存和未暂存的变更),也可审查远程代码合并请求(Pull Request,简称 PR)。审查的核心目标是保障代码的正确性、可维护性,并确保代码符合项目既定的规范标准。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 审查远程 PR

    当你完成功能开发或问题修复并提交 PR 后,可发起 AI 审查请求。你只需提供 PR 编号或 URL(例如:"Review PR #123"),AI 会自动检出(checkout)该 PR 的代码,运行项目预设的检查脚本(如 npm run preflight),同时阅读 PR 描述与评论以理解开发目标,随后对代码开展深度分析并给出反馈。

  • 审查本地代码变更

    若你希望在提交代码或创建 PR 前,先对本地修改进行审查,只需发出 “审查我的代码” 等类似指令即可,无需提供 PR 相关信息;AI 会通过 git statusgit diff 等命令,检查工作区中已暂存(staged)和未暂存(unstaged)的代码改动,进而对这些变更进行分析并反馈。

  • 提供深度分析与结构化反馈

    无论是审查远程 PR 还是本地代码变更,AI 都会从多维度开展深度的代码质量分析,涵盖正确性、可维护性、可读性与执行效率、安全性与测试完整性等维度。最终,AI 会以结构化形式输出反馈,内容包括总体概述、具体发现(关键问题、改进建议)以及明确的结论(如批准合并或要求修改)。

图片

网页应用测试

名称

webapp-testing

作者

Anthropic

地址

   

github.com/anthropics/…

   

简介

该 Skill 是一个基于 Playwright 构建的本地 Web 应用测试工具集,支持前端功能验证、UI 行为调试、页面截图及浏览器控制台日志采集,适配「先侦查后行动」的测试流程。工具集提供示例脚本与辅助脚本,覆盖静态 HTML 自动化测试、元素定位、日志抓取能力,并可在多服务场景下统一管理服务器生命周期。

资源文件

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • examples/console_logging.py: 演示了在自动化测试时如何捕获并保存网页的控制台日志,用于调试和监控。

  • examples/element_discovery.py: 展示了如何自动发现和列出页面上所有的按钮、链接和输入框等可交互元素,是编写自动化脚本前的重要步骤。

  • examples/static_html_automation.py: 示范了如何直接对本地的静态 HTML 文件进行自动化操作(如点击、填表),而无需通过网络服务器。

  • scripts/with_server.py: 自动化辅助脚本。在执行一个主命令(例如自动化测试脚本)之前,先启动一个或多个依赖的服务(如前端或后端服务器),并确保这些服务完全就绪后,再执行主命令,最后在命令结束后自动关闭所有服务。

应用场景

  • 自动验证前端功能

    当你在本地开发 Web 应用(如使用 React、Vue、Svelte 等框架),并希望验证某个新功能是否正常运行时,只需用自然语言告知 AI 测试需求(例如:“帮我测试登录功能”),AI 会自动编写 Playwright 脚本来模拟用户操作,并向你反馈页面状态或内容是否符合预期。

  • 调试与分析 UI 行为

    若你发现页面中某元素渲染异常或交互行为异常,可向 AI 下达指令(例如:“截取首页完整截图” 或 “检查按钮的 DOM 结构”),AI 会执行对应脚本,捕获截图或获取 HTML 内容,并将侦察结果返回给你,助力快速定位问题。

  • 处理需要后台服务的复杂交互

    若你的应用为前后端分离架构,测试前端功能需后端 API 服务同步运行,只需告知 AI 项目结构与启动命令,AI 会借助 with_server.py 脚本来同时启动所有必需服务,再运行测试脚本,确保测试在完整的环境中执行。

  • 测试静态 HTML 文件

    若你有不依赖服务器的纯静态 HTML 页面,需验证其内容或结构,只需向 AI 提供文件路径与测试需求,AI 会编写脚本并通过 **file:// **协议在浏览器中打开该文件,完成验证操作。

图片

CI/CD:PR 创建

名称

pr-creator

作者

google-gemini

地址

   

github.com/google-gemi…

   

简介

该 Skill 的核心作用是引导并自动化创建高质量、符合规范的拉取请求(Pull Request,简称 PR)。它通过标准化工作流程,确保每一次代码提交均遵循项目预设的模板与质量检查标准,从而提升代码审查效率、保障团队协作的一致性。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 一键创建符合规范的 PR

    当你在本地完成新功能开发或 Bug 修复,并已提交代码(git commit)后,可调用此 Skill,让 AI 自动执行分支检查、查找并应用 PR 模板、运行预检脚本(如测试和 linting),并最终生成一个标题和描述都完全符合项目规范的 PR。

  • 引导贡献者完成首次代码提交

    当新团队成员或外部贡献者不熟悉项目的提交流程和规范时,可以使用此 Skill,让 AI 以智能向导的形式,自动完成模板查找、脚本执行等繁琐操作,仅需用户填写必要的标题与描述,大幅降低代码贡献门槛。

  • 自动执行创建 PR 前的质量检查

    在正式创建 PR 之前,可以调用该 Skill,让 AI 自动运行项目预设的 preflight 脚本,执行所有必要的构建、单元测试和代码风格检查。如果任何检查失败,AI 会中止提交流程并提示开发者进行修复,节约了审查者的时间和精力。

图片

Linting 和格式错误修复

名称

fix

作者

facebook

地址

   

github.com/facebook/re…

   

简介

这个 skill 的核心作用是自动化地修复代码格式并检查代码规范(linting)错误 。它通过执行两个关键命令来保证代码质量:

  • **yarn prettier: **自动格式化已修改的文件,统一代码风格。

  • **yarn linc: **检查代码中是否存在 linting 错误(这些是 Prettier 无法修复的,例如未使用的变量、逻辑错误等),这些错误通常会导致持续集成(CI)失败。

最终目标是确保代码在提交前符合项目规范,从而顺利通过 CI/CD 流程。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 提交代码前的预防性检查

    在你完成编码,执行 git commit 之前,运行该 Skill,让 AI 自动清理代码格式,并提示任何需要手动修复的 linting 错误。

  • 修复已发现的 linting 或格式问题

    当你在编码过程中或接手他人代码时,发现当前工作区内存在明显的格式混乱或 linting 错误提示(例如,IDE 的警告),可以立即运行该 Skill,快速解决当前已知的代码质量问题,从而在开发过程中保持代码的整洁和可读性。

  • 解决持续集成(CI)失败问题

    当一个提交被推送到服务器后,CI 流水线报告了因 linting 或格式错误导致的失败。此时你可以在本地对应的分支上运行此 Skill,让 AI 自动修复格式问题,并列出需要手动更正的 linting 错误,帮助你快速定位并解决问题,然后提交修复。

图片

技术文档更新

名称

update-docs

作者

vercel

地址

   

github.com/vercel/next…

   

简介

该 Skill 是一套用于更新 Next.js 项目文档的引导式工作流,核心作用是帮助你根据源代码的变更,来分析、更新和创建相关的文档,确保代码和文档保持同步。它特别为审查 Pull Request (PR) 时的文档完整性检查而设计,通过一系列标准化的步骤来规范文档的修改过程。

资源文件

   

除 SKILL.md 文件外,该 Skill 中还包含以下文件:

  • references/CODE-TO-DOCS-MAPPING.md: 定义了源代码和文档之间的映射关系。简单来说,它就像一张地图,告诉 AI 当某个代码文件发生变化时,应该去更新哪个文档文件。
  • references/DOC-CONVENTIONS.md: 风格指南和规则手册,详细规定了文档的格式、结构和写作风格,以确保所有文档都保持一致性和高质量。

   

应用场景

  • 分析代码变更对文档的影响

    提交代码变更后,可以调用该 Skill 来分析哪些文档文件需要更新。

    它会通过 git diff 命令检查你的分支与 canary 分支之间的差异,并根据预定义的映射关系  (references/CODE-TO-DOCS-MAPPING.md),找出与变更的代码文件相对应的文档文件。

  • 更新现有的文档

    对于已经存在的文档,当其对应的功能或 API 发生变化时(例如组件新增了 props、函数行为变更),该 Skill 会引导你更新现有文档。

    它会提示你如何添加或修改 props 表格、更新代码示例、添加废弃通知等,并遵循项目固有的文档规范(例如,使用  ** ** /  ** ** 来区分不同路由的内容)。

  • 为新功能创建脚手架文档

    当你在项目中添加了一个全新的功能时(例如一个新的组件、函数或配置项),该 Skill 可以帮你快速创建符合规范的新文档。

    它为不同类型的文档(如 API 参考、指南)提供了标准模板,确保新文档的结构、命名和元信息(Frontmatter)都符合项目要求。

图片

查找 Skill

名称

find-skills

作者

vercel

地址

   

github.com/vercel-labs…

   

简介

该 Skill 主要作用帮助你发现并安装 Agent Skill。

它依托名为 skills 的命令行工具(CLI),让你可以从开放的 Agent Skill 生态中搜索、安装与管理各类模块化技能包;这些技能可扩展 Agent 能力,为其补充特定领域知识、标准化工作流与工具能力。

资源文件

该 Skill 中仅包含一个 SKILL.md 文件。

应用场景

  • 探索未知的 Skill

    当你希望 Agent 帮忙处理某个特定领域的任务,但不确定 Agent 是否具备相应能力时,可以使用此 Skill 进行探索。例如,当你询问 “你能帮我评审代码吗?” 或 “如何为我的项目生成文档?” 时,该 Skill 会被激活,主动在技能市场中搜索与 “代码评审” 或“ 文档生成” 相关的能力,并将找到的可用技能呈现给你.

  • 查找特定的 Skill

    当你明确知道需要一个 Skill 来解决特定问题,但不知道具体是哪个 Skill 时,可以主动调用此 Skill 进行精确查找。例如,你可以直接说 “帮我找一个用于 React 性能优化的 skill”,该 Skill 会将 “React 性能优化” 作为关键词进行搜索,并返回最匹配的技能选项,如 “vercel-react-best-practices”。

  • 提供可执行的 Skill 安装建议

    当该 Skill 找到一个或多个匹配的 skill 后,它会自动整理并输出一份标准化的推荐信息。这份信息不仅包含技能的名称和功能简介 ,还会提供 一键安装指令 (npx skills **add ... **) 以及指向技能详情页的官方链接。

欢迎大家安装尝试~先从会用 Skills 开始!


来源

❌
❌