普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月7日技术

Vue调试神器:Vue DevTools使用指南

2026年3月6日 21:52

image

一、初识Vue Devtools

Vue DevTools 概述

  在现代前端开发中,Vue.js 应用的组件化架构虽然提升了代码复用性,但也带来了复杂的状态管理和组件交互问题。当应用包含数十个嵌套组件时,传统的 console.log 调试方式如同在黑暗中摸索。Vue.js Devtools 作为官方调试工具,通过可视化界面将组件结构、状态变化和性能数据直观呈现,让开发者能够像"透视"一样观察应用内部运行机制。

image

  Vue Devtools 是 Vue 官方发布的调试浏览器插件,可以安装在 Chrome、Firefox、Edge等浏览器上,可以帮助我们监控和管理 Vue 应用的状态、事件和性能。通过 Vue Devtools,我们可以查看组件的结构、属性和方法,以及父子组件之间的关系。此外,Vue Devtools 还提供了时间轴功能,让我们可以更好地了解应用的状态变化。

Vue DevTools 功能说明

  1. 组件树检视:能够清晰展示出应用中的组件层级结构,方便开发者理解和导航。
  2. 状态和数据查看:可以检查组件的状态,包括props、data、computed properties等。
  3. 调试事件:可以监听和触发事件,便于开发者查看事件的响应和效果。
  4. 时间旅行:这是 Vue DevTools 的高级功能之一,能够记录组件的快照,允许开发者在不同的快照之间切换,观察应用状态的变化。
  5. 控制台集成:Vue DevTools 提供了集成到浏览器控制台的能力,可以通过控制台直接与Vue实例交互。
  6. 组件信息展示:可以查看每个组件所对应的虚拟DOM结构和渲染细节。

二、环境适配:多场景下的安装与配置

浏览器扩展

  目前 Vue DevTools 主要支持 Chrome 浏览器和 Firefox 浏览器,并提供对应的浏览器扩展。对于其他平台(如Safari或Edge)的支持情况,可以通过各种主流浏览器的扩展商店进行安装。

插件:www.chajianxw.com/developer/1…

  打开 Chrome 浏览器,选择菜单“更多程序”→“扩展程序”,打开扩展程序界面,打开开发者模式,单击“加载已解压的扩展程序”按钮,将vue-devtools插件安装到Chrome 浏览器,安装结果如图:

image

  安装完成后,开发者需要在浏览器的扩展管理页面启用Vue DevTools。在使用Vue DevTools时,通常需要在Vue应用中直接运行,这时DevTools会自动识别并展示调试信息。若未看到,刷新页面或检查是否为 Vue 应用。

image

Vite Plugin

单体应用

对于Electron应用、移动端应用(NativeScript/Capacitor)或者服务端渲染应用,浏览器扩展可能无法直接使用。别担心,Vue Devtools还提供了NPM包版本

npm install -g @vue/devtools

Vue DevTools 默认仅适用于 Vue 的开发版本(非压缩版),在生产环境中默认禁用,否则就好比把家里的“透视眼镜”给小偷戴上,会暴露应用内部状态。

三、功能解析:掌握调试工具的核心能力

  在安装了 Vue Devtools 的浏览器中,打开你的 Vue 应用。然后右键点击页面,选择“Inspect”,在弹出的开发者工具中找到“Vue”选项卡,点击即可打开 Vue Devtools。

3.1 Components面板:组件世界的“上帝视角”

  在现代的前端开发中,组件化已经成为一种标准的实践方式。Vue.js 也不例外,它提供了一种灵活的方式来构建用户界面,通过组件树的层级结构来组织界面的不同部分。在 Vue 应用中,组件的父子关系是通过组件嵌套和属性传递来定义的。父组件通过在模板中声明子组件标签,并通过 props 将数据传递给子组件,从而建立起父子关系,Vue Devtools 提供了一个直观的方式来查看组件之间的这种层级结构。

  在 Vue DevTools 的“Components”标签页中,可以直观地看到整个应用的组件树结构,类似于文件系统的目录结构,从根组件(Root)开始,层层展开,让我们可以更好地了解组件的结构。每个组件都是一个节点,父组件之下包含子组件,形成清晰的层级关系。通过展开组件节点,可以查看其子组件,帮助开发者快速定位问题发生的组件区域。在组件树视图中,可以通过输入关键字来筛选组件,快速定位到关心的组件,这对于大型应用中组件众多的情况非常实用。

image

  在组件树中,选中某个组件后,右侧面板会显示该组件的属性、数据、计算属性和方法等信息。开发者可以实时查看组件状态的变化,无需在控制台中进行繁琐的打印操作。

image

  组件树中的每个组件节点不仅显示了组件的类型,还可以展开来查看其详细信息,包括组件的属性、数据、计算属性以及样式等。最刺激的是实时编辑功能——直接在Devtools中直接修改组件的 data 属性值,比如把一个按钮的 disabled 从 true 改为 false ,页面上的按钮立即变得可点击!无需刷新页面,无需重新编译,就像用手指直接拨动乐高积木一样神奇。这对于调试数据驱动的问题非常有帮助,能够快速验证数据的正确性和对组件的影响。

image

3.2 Events面板:事件流的“监听器”

  在 Vue Devtools 中,Events 面板用来监控Vue实例的所有事件。

  • 事件历史:按时间顺序显示所有触发的Vue事件(包括自定义事件)
  • 按组件筛选:只看某个特定组件触发的事件
  • 事件详情:点击事件可查看事件名称、目标组件、传递参数等信息
  • 复制数据:支持将事件数据复制到剪贴板

这对于调试复杂的组件通信(比如爷孙组件传值、兄弟组件通信)非常有用,帮助我们更好地了解事件的处理情况。

3.3 状态追踪:应用数据的"黑匣子记录仪"

  如果应用使用了Vuex(Vue 2)或Pinia(Vue 3官方推荐),Vue Devtools 会自动显示状态面板,这个面板就是你的“中央监控室”。左侧显示完整的 store 状态树,所有数据一目了然。可以展开每一个节点,查看当前所有共享状态的值。在这里,我们可以查看state、getters、mutations(Vuex)或actions(Pinia),以及它们的详细信息。通过时间线视图,开发者可以查看状态树是如何随时间变化的,帮助理解状态变化的流程。

3.4 最炫酷的“时间旅行”

  Vue Devtools 提供了一个时间轴功能,可以让我们更好地了解应用的状态变化。在时间轴中,我们可以查看每个组件的状态变化,以及它们之间的依赖关系。开发者可以回溯到过去的状态,进行状态差异的比较分析。这对于调试复杂的状态管理逻辑非常有用,能够快速定位状态变化导致的问题。

3.5 Router面板:路由导航的“导航仪”

  如果应用使用了Vue Router,Router 面板就是你的“导航仪”。在“Router”标签页中,可以查看当前路由的信息,包括路径、查询参数、路由参数等,如下图所示。

image

  同时,还能看到路由的历史记录,方便开发者了解应用的导航流程。通过观察路由的变化,开发者可以调试路由跳转、参数传递等问题。例如,当遇到路由跳转后页面不更新的问题时,可以通过查看路由变化记录,分析错误发生的原因。

3.6 Timeline面板:应用优化的"体检报告"

如何录制性能数据

  1. 切换到Timeline面板
  2. 点击左上角的“Start recording”(开始录制)按钮
  3. 在页面上执行你想要分析的操作(比如点击一个会加载大量数据的按钮)
  4. 点击“Stop recording”停止录制

数据解读:谁在“摸鱼”?

录制完成后,你会看到类似心电图的时间轴:

  • 组件渲染时间:每个组件从开始渲染到完成花了多久
  • 组件更新次数:某些组件是不是在“无效加班”(频繁无意义地重新渲染)
  • 生命周期钩子执行时间:比如mounted钩子里是不是放了太多代码导致阻塞

性能优化实战案例

通过Timeline面板,你可能会发现:

  • 某个表格组件渲染要500ms → 考虑使用虚拟滚动
  • 某个computed属性被频繁重新计算 → 考虑使用缓存或shallowRef
  • 某个组件在父组件更新时跟着乱更新 → 添加v-once或合理使用key

四、总结

  Vue Devtools是一款非常实用的工具,可以帮助我们更好地理解和管理Vue应用。使用 Vue DevTools 进行调试与性能优化,能够极大地方便开发者的工作。通过可视化 的组件树、实时数据修改、Vuex 状态跟踪及时间旅行功能,我们可以更加高效地定位问题,优化处理逻辑,提升应用性能。

image

三维模型瓦片服务三剑客:3D Tiles、I3S与S3M全解析

作者 charlee44
2026年3月6日 21:10

本文节选自新书《GIS基础原理与技术实践》第8章。当 GIS 迈入三维时代,如何高效发布与可视化海量三维模型成为关键挑战。目前,Cesium 的 3D Tiles、Esri 的 I3S 和 超图的 S3M 已成为三大主流三维瓦片标准。本文将带你深入其核心机制——从瓦片树、包围体、几何误差,到 b3dm/i3dm/pnts 格式细节,再到要素化与声明式样式,全面解析这“三维瓦片三剑客”的异同与适用场景。

GIS基础原理与技术实践

8.8 三维模型数据服务

与矢量切片服务和地形切片服务一样,三维模型数据服务也多数是以静态资源的形式进行发布的,毕竟他们还没形成比较标准的规范,不用提供额外的空间操作,只需要保证能获取资源进行可视化就可以了。因此,三维模型数据服务大多直接使用三维模型瓦片数据格式发布的静态资源即可。

8.8.1 三维模型瓦片数据格式

一般情况下,三维模型的数据量比单纯的栅格数据或者矢量数据大得多,因此也需要进行类似于切片的处理,将三维模型轻量化。其具体的原理也不复杂,使用的就是在第7.4节中我们介绍的分页LOD技术,通过分层和分块,将三维模型划分成不同精细度、不同范围的瓦片,根据三维场景的需要,使渲染端动态调度出适配场景精细度的三维模型瓦片。

第7.4节中我们是通过倾斜摄影模型介绍的具有分页LOD技术的OSGB格式数据,但推而广之,其实第7.5节中介绍的所有类型的三维模型数据都可以使用OSGB格式来进行表达。不过,OSGB格式数据是一个适合桌面端的数据格式,并没有针对Web端环境进行优化和适配。目前,经常用作三维模型数据服务的三维模型数据是Cesium的3D Tiles格式,ArcGIS的I3S格式以及国内超图软件的S3M格式。其中,3D Tiles和I3S已经是国际OGC标准,而S3M则是CAGIS(中国地理信息产业协会)空间三维模型数据格式标准。

根据3D Tiles官方文档(github.com/CesiumGS/3d… 提供的定义,3D Tiles是专为流式传输和渲染大量3D地理空间内容而设计的三维模型数据格式,例如倾斜摄影测量数据、3D建筑数据、BIM/CAD、实例化要素和点云数据等。与OSGB使用的分页LOD技术类似,3D Tiles使用分层细节级别 (HLOD,Hierarchical Level of Detail)的空间数据结构,保证只有可见的瓦片才会被流式传输和渲染,从而提高三维模型数据整体性能。

3D Tiles有1.0和1.1两个版本,但是目前3D Tiles 1.0是使用最广泛的三维模型瓦片数据格式,以下我们会以3D Tiles 1.0为例,具体介绍一下三维模型瓦片数据格式的内容。

8.8.2 瓦片集和瓦片(Tilesets and Tiles)

3D Tiles合适文件通常是一个散列的包含文件和文件夹的数据集,数据集的入口通常是一个名为tileset的JSON文件。如文件名表达的含义一样,这个JSON文件就是3D Tiles的根数据集(Tilesets),一个典型的例子如下例8.6所示:

例8.6 3D Tiles的根数据集

{
    "asset": {},
    "properties": {},
    "geometricError": 100,
    "root": {
        "geometricError": 20,
        "boundingVolume": {
            "region": []
        },
        "refine": "ADD",
        "children": [
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "house.b3dm"
                },
                "children": [
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsA.b3dm"
                        }
                    },
                    {
                        "geometricError": 5,
                        "boundingVolume": {},
                        "content": {
                            "uri": "detailsB.b3dm"
                        }
                    }
                ]
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "tree.pnts"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "fence.i3dm"
                }
            },
            {
                "geometricError": 10,
                "boundingVolume": {},
                "content": {
                    "uri": "external.json"
                }
            }
        ]
    }
}

在这个JSON文件中,最主要的部分就是名为root的元素,以及children数组中的元素。其实两者的属性是相同的,都应该包含content、children、boundingVolume、geometricError以及refine键值对,只不过有的键值对被省略掉了。具体来说,3D Tiles中的瓦片(Tiles),指的就是这个元素。

从例8.1可以看出,root元素包含了一个children数组元素,children数组中的一个元素又可以包含一个children数组元素...如此可以进行多层嵌套,就组成了一个瓦片树。我们可以回忆一下第7.4.3节的内容,这与OSGB格式的节点树非常相似。在这个瓦片树中,越往上,模型精细度越低,但是分块越少;越往下,模型精细度越高,但是分块越多。父亲节点与所有的子节点表达的数据内容是一样的,只是精细度有差别。

3D Tiles瓦片和瓦片集的示意图如下图8.53所示,一个JSON瓦片集能包含多个瓦片,瓦片的content就是具体的模型实体。不过正如例8.6所展示的那样,瓦片的content也可以指向另一个JSON瓦片集,像这样重复嵌套,我们可以组成一个非常复杂的表达三维场景的瓦片树。

图8.53 3D Tiles瓦片和瓦片集的示意图

8.8.3 包围体(Bounding Volumes)

在例8.6中我们就看到的boundingVolume元素就是包围体。可能这里说包围盒这个概念更容易让人理解一点,但是3D Tiles中有三种不同的表达切片范围的体要素,所以将其称为包围体更好一点。这三种包围体分别是包围盒(Bounding Box),包围球(Bounding sqhere)和包围区域(Bounding region),如下图8.54所示:

图8.54 3D Tiles瓦片不同包围体类型

包围盒是我们最熟悉的,但是这里反而最不好理解,一个包围盒参数的例子如下所示:

"boundingVolume": {
    "box": [
        0, 0, 10,
        20, 0, 0,
        0, 30, 0,
        0, 0, 10
    ]
}

可以看到这里一共12个参数,前3个参数表示中心点的位置坐标,接下来的三个元素定义x轴方向和半长,再接下来的三个元素定义y轴方向和半长,最后三个元素定义z轴方向和半长。这个例子的包围盒的描述就是,中心点坐标为(0,0,10),X方向长度为40,Y方向长度为60,Z方向为20。这种包围盒在三维中称为AABB(Axis-Aligned Bounding Box,轴对称包围盒)包围盒,一般情况下这么用就可以了。

但是如果深入了解一下,就会发现12个参数中有很多值是0,这些0值其实是用来表达旋转的,或者说方向的。AABB包围盒其实对三维物体对象的贴合不够紧密,如果调整一下包围盒的方向,就有可能让包围盒的范围进一步缩小(想象一下从西北到东南的长条状物体的包围盒)。这种包围盒就被称为OBB包围盒(Oriented Bounding Box,有向包围盒)。复习前面第3.7.1节的知识就会明白,后面9个参数实质是定义了旋转变换+缩放变换的几何变换矩阵。因为OBB包围盒的方式复杂一些,所以这种表达形式使用的比较少。

而包围球就最简单了,由中心点坐标和半径定义四个参数定义,如下所示:

"boundingVolume": {
    "sphere": [
        10, 5, 15,
        140.0
    ]
}

最后的包围区域则是三维图形中没有的概念,实际上这个区域其实指的是地理区域,由6个参数定义,分别是WGS84坐标系中西至经度,南至纬度,东至经度,北至纬度,最小椭球高,最大椭球高,经纬度使用弧度为单位,高度以米为单位。如下所示:

"boundingVolume": {
    "region": [
        -1.319700,
        0.698858,
        -1.319659,
        0.698889,
        0.0,
        20.0
    ]
}

包围体是三维图形中就非常重要的参数,可用于优化渲染和高效空间查询,例如在Ceisum中,就通过使用包围体实现可见性查询和视锥体剔除,显著提升了渲染性能。

8.8.4 空间数据结构

我们在前面论述过,3D Tiles中的瓦片集以树形数据结构进行组织。但是,这种树形数据结构不是任意组织的,而是具有空间一致性:父瓦片的包围体始终包含其所有子瓦片的内容。这对于可见性测试和相交性测试特别重要,当在三维场景中我们看不到某个瓦片的时候,那么必然看不到它的所有子瓦片。通过这种方式,我们可以筛选需要的瓦片进行展示,这对性能的提升非常有帮助。

另外,与基于二维的地图切片不同,3D Tiles的瓦片数据结构通常是基于三维的,因此要更加复杂,例如KD树或者八叉树,且每个瓦片可能并不均匀。这样可能就会造成一个现象,就是父瓦片的包围盒可能并不能完全包含子瓦片的包围盒。当然,父瓦片的包围体包含其子瓦片的内容的特性还是存在的。具体的空间结构示意图如下图8.55所示:

图8.55 3D Tiles空间数据结构示意图

8.8.5 几何误差(Geometric Error)

几何误差(Geometric Error)就是例8.6中的geometricError元素。复习一下我们在第7.4.3节中介绍的知识,OpenSceneGraph和OSGB格式使用瓦片包围球映射到屏幕端直径来决定渲染的精细度层级;而几何误差的作用也非常类似,决定了3D Tiles在渲染客户端(如Cesium)以何种细节级别进行渲染,从而在性能和渲染质量之间提供最佳权衡。

虽然都是控制LOD级别的因子,3D Tiles格式的几何误差表达的含义则与OSGB格式使用的参数完全不同,几何误差表达的含义是简化的几何体与真实的几何体之间的误差,以米为单位。在可视化端实现的时候,会将这个参数转换成屏幕空间误差(screen-space error,SSE),单位为像素。当SSE超过某个阈值(CesiumJS中会设定一个最大屏幕空间误差值)的时候,运行的时候将会渲染更高级别的细节。具体示意图如下图8.56所示:

图8.56 3D Tiles中的几何误差和屏幕空间误差

那么,几何误差是如何转换成屏幕空间误差呢?Cesium官方给出了一个公式,对于透视投影,他们的转换公式如下式(8-3):

sse=geometricErrorscreenHeighttileDistance2tan(fovy/2)(8-3)sse = \frac{geometricError ⋅ screenHeight}{tileDistance ⋅ 2 ⋅ tan(fovy / 2)} \tag{8-3}

其中,screenHeight是渲染屏幕的高度(以像素为单位),tileDistance是瓦片到视点的距离,fovy是视锥体的y方向的张角。

8.8.6 细化策略(Refinement Strategies)

细化策略(Refinement Strategies)就是例8.6中的refine参数。这个参数决定了以何种方式在高细节层级瓦片中增加细节。通常的方式是替换(REPLACE),意思是子瓦片节点会替换其父瓦片,这也是OSGB格式采取的策略;Cesium中还额外支持新增(ADD),意思是子瓦片在父瓦片的基础上,增加新的内容。具体示意图如下图8.57所示:

图8.57 3D Tiles中的细化策略

每个瓦片都可以设置细化策略参数,如果未指定,说明该瓦片的细化策略继承自父瓦片。

8.8.7 渲染优化算法

假设已经存在一个3D Tiles瓦片集和相机视锥体如下图8.58所示。3D Tiles瓦片集我们比较好理解,关键元素我们已经在前面几小节中介绍过了。相机视锥体是三维图形中经常要用到的一个概念,好比真实世界中,我们需要拍摄到一个物体,必须让相机调整到合适的位置(Position),调整好合适的角度(Orientation)以及调整合适的焦距(Field-of-view angle,视场角)。

图8.58 3D Tiles瓦片集和相机视锥体

接下来,我们可以模拟出在可视化客户端渲染实现中,3D Tiles格式是如何平衡任何比例的渲染性能和视觉质量了。虽然我们在前面中已经将这个思想(分页LOD机制/HLOD)论述了很多次了,但这里我们可以对照下图8.59所示进行进一步理解:

  1. 最开始加载的是JSON格式的瓦片集文件,并测试视锥体与根瓦片边界体积是否相交。在这里,视锥体与根瓦片的包围体相交,这意味着该瓦片可能需要被加载进行渲染。
  2. 由于根瓦片是没有内容的,那么就测试子瓦片的包围体与视锥体的相交。在这里,三个子瓦片中的两个的包围体确实与视锥体相交,这意味着这些子瓦片的内容会被考虑进行渲染;而剩下的一个瓦片就被直接剔除不用渲染。
  3. 此时检查瓦片的几何误差,根据式(8-3)计算此时的屏幕空间误差。此时由于没有超过阈值18.0,说明内容呈现的精细度正好合适。
  4. 然后,当用户进行交互,例如放大某个建筑物时,根据式(8-3)可知瓦片的屏幕空间误差会增大而超过阈值,有可能需要进行下一层级的渲染。并且新的视锥体可能会剔除更多的瓦片不用渲染,只有一小部分瓦片集可见。
  5. 根据所选的细化策略加载和渲染具有较高细节级别的内容。由于较高细节级别瓦片的几何误差较小,导致屏幕空间误差低于阈值,此时可以呈现更高精细度的视觉质量。

图8.59 3D Tiles中的细化策略

8.8.8 瓦片内容数据

3D Tiles瓦片内容数据通常以URI的形式引用外部文件,如例8.6中的house.b3dm、detailsA.b3dm和detailsB.b3dm。因为这些文件是3D Tiles瓦片的主体,所以很多情况下为了方便使用就将其当成瓦片本身。3D Tiles瓦片的格式可以有以下四种表现形式:

  1. Batched 3D Model(b3dm):批处理三维模型,最常规的三维模型。
  2. Instanced 3D Model(i3dm):实例化三维模型,相同三维模型的多个实例。
  3. Point Clouds(pnts):点云,大量点组成的数据。
  4. Composite Tiles(cmpt):以上三种的复合数据。

3D Tiles瓦片其实就是一种普通的三维模型数据,我们可以按照第7章三维模型介绍的内容来理解它。不过3D Tiles瓦片与普通三维模型最大的不同就在于它是按照GIS矢量要素特性来进行设计的,具体来说,就是3D Tiles瓦片中除了三维模型之外,还有要素表(Feature Table)和批处理表(Batch Table)来作为属性数据。另一方面,三维模型自身也被逻辑上拆分成多个要素模型,通过ID与属性表相关联。实际上,正如第7.5.2节中所述,这种设计实现了三维模型的单体化,在业务应用中有很大的实用意义。

1. 批处理三维模型(Batched 3D Models)

批处理三维模型(Batched 3D Models,b3dm)是3D Tiles常用的瓦片数据格式,因为其本质上就是最常规的三维模型数据。具体有多常规呢,b3dm内部直接嵌入了一个我们在第7.2节中介绍的glTF三维模型文件,具体数据布局如下图8.60所示。根据其数据布局,我们可以作一个大概的说明:

  • magic是魔法值的意思,其实就是文件标识符,具体就是“b3dm”四个字符。
  • version和byteLength分别代表版本和整个b3dm文件的字节长度。
  • featureTableJSONByteLength、featureTableBinaryByteLength、batchTableJSONByteLength和batchTableBinaryByteLength的大小分别描述了要素表JSON部分的字节长度、要素表二进制部分的字节长度、批处理表JSON部分的字节长度、批处理表二进制部分的字节长度。
  • 文件主体包含三个部分,分别是要素表(这是必须的),批处理表(可选的)以及内嵌的glTF三维模型文件。

图8.60 3D Tiles的b3dm格式瓦片数据布局

b3dm的文件数据组织我们已经初步了解,那么是如何将三维模型其拆分成多个要素模型呢?方法很简单,是通过扩展了一个名为batchId的顶点属性来实现的。对于不同的要素模型,我们分别赋予其不同的batchId值,这样在将三维模型渲染成二维画面的时候,通过二维画面像素关联的batchId值,我们就区分哪些画面像素是属于哪个要素的。如下图8.61所示:

图8.61 b3dm中不同的要素模型存储的不同的batchId值

现在已经有了batchId值了,那么我们就需要将其关联到要素表和批处理表。对于b3dm瓦片格式来说,图8.61对应的要素表的JSON部分通常为:

{
    "BATCH_LENGTH": 2
}

BATCH_LENGTH是要素表的必须属性,表示要素的个数为2。b3dm通常不使用要素表的二进制部分,而将要素模型的属性数据放入到批处理表中。例如,图8.61对应的批处理表的JSON部分通常为:

{
    "height": [
        16.2
        23.0,        
    ],
    "address": [
        "234 Second Street",
        "123 Main Street"
    ]
}

这里表达了批处理表中高度字段属性和地址字段属性,每个字段属性值都是一个数组元素,而batchId就是这个数组元素的索引。很显然,这正是batchId关联属性表的关键:第1个模型要素的高度是16.2,地址是234 Second Street;第2个模型要素的高度是23.0,地址是123 Main Street。

一般情况下,只使用批处理表的JSON部分就可以表达要素模型的属性表了。批处理表的二进制部分则是用来配合JSON部分来表达特定数据类型的属性,例如当JSON部分为如下所示时:

{
    "location": {
        "byteOffset": 0,
        "componentType": "FLOAT",
        "type": "VEC2"
    },
    "id": {
        "byteOffset": 32,
        "componentType": "INT",
        "type": "SCALAR"
    }
}

那么location和id属性字段值就会在二进制部分中进行查找,byteOffset表示起始位置字节偏移,type表示数据类型,componentType则表示数据分量类型。其实这三个参数与glTF中的顶点属性数据的表达非常像,type和componentType值的要求也与glTF中值的要求一致,复习以下第7.2节中glTF的介绍就会非常容易理解。

话说回来,我们说b3dm是参照矢量要素的设计思路实现的,是从GIS的角度进行出发论述。其实从“批处理”这个命名来说,设计者更多的是从图形渲染的角度出发来进行设计的。在图形渲染行业中,术语“批处理”是指多个模型的几何数据进行合并,组合成单个的缓冲区进入GPU显存中进行渲染,这样可以减少复制操作带来的损耗,最小化渲染绘制调用次数,从而提高渲染性能。不得不说,b3dm的设计确实很精妙,很多学问到了最深处往往都是相通的。

2. 实例化三维模型(Instanced 3D Models)

有了b3dm作为基础,实例化三维模型(Instanced 3D Model,i3dm)就比较容易理解了。不过,我们首先需要知道为什么这种瓦片格式叫做实例化三维模型。其实“实例化”这个术语是图形渲染中的一种技术,通过实例化技术可以一次性渲染大量相同的模型,只不过这些模型有一些特定的变化。例如我们渲染大量的树木,我们可以使用同一个树木模型,然后让每个树木模型的位置、旋转和缩放不同,就可以得到一大片形态各异的树林。实例化的优点就在于,既然创建一个树木对象进行渲染是很耗费性能的,那么就将这个树木对象改变一下位置、朝向以及大小进行复制粘贴,这样就可以很轻易绘制出包含大量三维模型数据的场景,并且能保证性能。

实例化技术具有非常多的应用场景,因为很多现实中的物体是有规范和标准的,比如城市中的部件,BIM中的基础设施,工业设计中的零件等,它们往往都有非常相似的外观,使用实例化技术可以有非常好的效果。这也是为什么3D Tiles将实例化三维模型作为一种瓦片数据格式。

从前面的介绍不难理解,i3dm相比较普通三维模型数据,最大的区别在于多了表达变化的实例化参数(比如前面提到的位置、旋转和缩放)。i3dm实例化参数信息是放置在要素表中的,因此,i3dm瓦片数据布局与b3dm瓦片数据布局基本一致,如下图8.62所示:

图8.62 3D Tiles的i3dm格式瓦片数据布局

除了多了一个表达gltf是外部还是内嵌的参数gltfFormat,i3dm与b3dm最大的不同就在于要素表和批处理表。要素表中需要存放实例化参数,例如一个要素表的JSON部分如下所示:

{
    "INSTANCES_LENGTH": 3,
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL_UP": {
        "byteOffset": 36
    },
    "NORMAL_RIGHT": {
        "byteOffset": 72
    },
    "SCALE": {
        "byteOffset": 108
    }
}

INSTANCES_LENGTH是必须的参数,表示实例化个数。POSITION、NORMAL_UP、NORMAL_RIGHT和SCALE是预先定义好的语义,分别表示位置、旋转的上方向、旋转的右方向以及缩放,它们分别用3个float型、3个float型、3个float型以及1个float型来表示,配合起始位置字节偏移byteOffset,我们可以很容易找出存储在要素表二进制部分的实例化参数,如下图8.63所示:

图8.63 i3dm中的实例化参数

另外,i3dm也是遵循要素化的设计思路的,不过与b3dm不同,i3dm是以单个的实例化对象为单个要素,并且关联属性。在要素表中,可以在JSON部分增加一个名为BATCH_ID的语义,在二进制部分存储不同实例化对象的batchId值。而批处理表中则像b3dm一样进行存储其他属性数据,这样就实现了单个的实例化模型与属性信息的关联。

3. 点云(Point Clouds)

相比较b3dm和i3dm,点云(Point Clouds,pnts)形式的瓦片数据格式就更加简单了,甚至不用内嵌glTF。点云pnts的数据布局如下图8.64所示:

图8.64 3D Tiles的pnts格式瓦片数据布局

点云除了记录点的位置属性之外,还可能有法向量、颜色等属性,这些属性数据都是记录在要素表中的。如下所示是一个pnts要素表的JSON部分:

{
    "POINTS_LENGTH": "219",
    "POSITION": {
        "byteOffset": 0
    },
    "NORMAL": {
        "byteOffset": 2628
    },
    "RGB": {
        "byteOffset": 5256
    }
}

类似i3dm的要素表,这里的POINTS_LENGTH表示点的个数,而POSITION、NORMAL和RGB这些属性名称也是预定义的语义类型,配合起始位置字节偏移量byteOffset可以找到点属性具体的属性值,具体示意图如下图8.65所示:

图8.65 pnts将点云属性存储在要素表中

pnts也是遵循要素化的设计思路,从要素表来看,似乎点云中一个点就是一个要素,但这样理解并不准确。pnts需要表达的是一个要素模型,例如一个点云瓦片表示的是一个房屋,那么房屋内部中的门、窗或者屋顶才是我们想要知道的要素模型。要实现这样的要素识别非常简单,还是使用如同b3dm或i3dm相同的办法,在要素表中增加一个名为BATCH_ID的字段,记录每个点云的batchId值,如下图8.66所示:

图8.66 pnts通过Batch ID区分不同的点云要素

剩下的就还是如同b3dm一样,在批处理表中存储其他属性数据,实现多个点组成的要素模型与属性信息相关联。

4. 复合瓦片(Composite Tiles)

复合瓦片(Composite Tiles,cmpt)是以上介绍的瓦片格式的复合数据格式。举例来说,一组建筑物可以存储在b3dm中,一组树木可以存储在i3dm中,如果这些元素出现在同一地理位置时,就可以将其组合成cmpt,实现单个的请求获取该地理位置所有的可渲染内容,如下图 8.67所示。这样的设计可以减少访问的请求个数,改善瓦片数据加载时的视觉效果。

图 8.67 3D Tiles的cmpt格式瓦片实现示意图

cmpt的数据组织非常灵活,可以包含b3dm、i3dm和pnts中的任意种类任意个数的瓦片数据,甚至可以包含另一个cmpt瓦片数据。但它的数据布局就简单了,如下图8.68所示。文件头通过tilesLength标识包含的子瓦片的个数,文件主体则是具体的子瓦片数据内容。

图8.68 3D Tiles的pnts格式瓦片数据布局

8.8.9 声明式样式(Declarative Styling)

既然3D Tiles的瓦片数据格式是按照要素特性来进行设计的,那么免不了要面对的就是模型要素符号化的问题。3D Tiles使用声明性样式在运行时修改功能的外观,所谓声明性样式,具体来说就是包含一组表达式的JSON。这种样式JSON规定了一些变量,表达式以及条件,可以看作是一种简单的样式语言。例如我们让一组表达建筑的3D Tiles根据其高度呈现不同的颜色,可以使用如下样式JSON:

{
    "color": {
        "conditions": [
            ["${height} >= 300", "rgba(45, 0, 75, 0.5)"],
            ["${height} >= 200", "rgb(102, 71, 151)"],
            ["${height} >= 100", "rgb(170, 162, 204)"],
            ["${height} >= 50", "rgb(224, 226, 238)"],
            ["${height} >= 25", "rgb(252, 230, 200)"],
            ["${height} >= 10", "rgb(248, 176, 87)"],
            ["${height} >= 5", "rgb(198, 106, 11)"],
            ["true", "rgb(127, 59, 8)"]
        ]
    }
}

其中,color是要素模型的颜色值属性,决定要素模型渲染的颜色。height则表示3D Tiles瓦片中批处理表种的height字段,根据这个字段值的不同,给模型要素赋予不同的颜色。在CesiumJS中实现效果如下图8.69所示:

图8.69 3D Tiles的声明式样式的效果图

虽然很多写实的三维模型可能用不到这个功能,但是这个设计实现在业务系统中很有用处,也很容易扩展,可以帮助我们实现更酷炫更有价值的可视化效果,值得我们进一步研究。

8.8.10 其他

从以上对3D Tiles格式的介绍可以感受到,3D Tiles确实是设计的非常完善的三维模型瓦片数据格式,也因此得到了最为广泛的使用。除此之外,另一个OGC标准——ArcGIS设计的I3S(Indexed 3D Scene Layers)三维模型瓦片数据格式也很优秀,与3D Tiles相比,它的一些特点给笔者留下了比较深刻的印象,主要是:

  • 3D Tiles是离散文件集形式的静态资源,I3S则可以打包成.slpk这种zip格式的单文件,也支持使用RESTful接口访问。
  • 3D Tiles空间坐标参考默认是WGS84椭球的地心地固坐标系,少部分参数使用WGS84地理坐标系;而I3S则专业很多,支持目前绝大多数地理空间坐标参考。
  • 不知道是否是处于兼容性的考虑,I3S设计的参数非常多,但可视化的时候很多参数都没有用上(这也是ArcGIS的一贯特色);3D Tiles这方面则简练很多,只提供了最简单的参数要求,其余的需求通过扩展来实现。
  • I3S在设计中实现了几何数据、属性数据、纹理材质的解耦,这意味着这些资源可以共享,在一些渲染实现中可以通过这种机制来提升性能。
  • I3S确定LOD层级的算法与3D Tiles不同,而跟OSGB比较类似,通过计算包围球投影到屏幕空间的像素大小来确定。

而I3S其余的设计实现,基于与3D Tiles大同小异,笔者这里就不多作介绍了。值得一提的是,I3S虽然没有提供具体的代码实现,但是其官方在线文档 github.com/Esri/i3s-sp… 中提供了一个可用于浏览I3S数据的在线浏览器,以及各个版本的I3S数据下载,这对于我们的研究学习很有帮助。

最后,国内还有一种使用的比较多的三维模型瓦片数据格式:主要由超图软件开发设计的S3M(spatial 3D model)格式。尽管S3M是中国地理信息产业协会的空间三维模型数据格式标准,但这个格式笔者接触的不多,毕竟愿意使用S3M格式的数据,多少有点敏感性,是不太容易获取进行研究的。

不过,笔者还是查阅了一下S3M官方在线文档(github.com/SuperMap/s3… Tiles和I3S最有诚意的一点是除了提供与其他三维瓦片数据的转换工具,还提供了读写S3M瓦片数据的JavaScript和C++代码实现,并且一直在更新。不过,缺点就是文档不够完善,至少笔者也没有看到S3M1.0、S3M2.0和S3M3.0不同版本之间的演进。而仅存的一版S3M标准文档的内容,相对于3D Tiles文档中完善的技术指导和参数说明也失之简陋。重于实现而轻于文档,这一点也只能说是国内开源工作的通病了。


本文节选自作者新书《GIS基础原理与技术实践》第8章。书中系统讲解 GIS 核心理论与多语言实战,适合开发者与高校师生。

📚 配套资源开源GitHub | GitCode 🛒 支持正版京东当当

昨天 — 2026年3月6日技术

useradd Cheatsheet

Basic Syntax

Core useradd command forms.

Command Description
sudo useradd username Create a user account with defaults
sudo useradd -m username Create user and home directory
sudo useradd -m -s /bin/bash username Create user with explicit login shell
sudo useradd -m -c "Full Name" username Create user with GECOS/comment field
sudo useradd -D Show current default useradd settings

Home Directory and Shell

Set home path and login shell at creation time.

Command Description
sudo useradd -m username Create /home/username if missing
sudo useradd -M username Create user without home directory
sudo useradd -d /srv/appuser -m appuser Create user with custom home path
sudo useradd -s /bin/zsh username Set login shell to Zsh
sudo useradd -s /usr/sbin/nologin serviceuser Disable interactive login for service account

Groups and Permissions

Assign primary and supplementary groups during creation.

Command Description
sudo useradd -m -g developers username Set primary group to developers
sudo useradd -m -G sudo username Add user to supplementary sudo group
sudo useradd -m -G docker,developers username Add user to multiple supplementary groups
id username Verify UID, GID, and group membership
groups username Show group memberships for a user

UID, Expiry, and Inactive Policy

Control account identity and lifetime.

Command Description
sudo useradd -m -u 1050 username Create user with specific UID
sudo useradd -m -e 2026-12-31 username Set account expiration date
sudo useradd -m -f 30 username Disable account after 30 inactive days
sudo useradd -m -k /etc/skel username Use skeleton directory for initial files
sudo chage -l username Inspect account aging and expiry policy

Password and Account Activation

Set password and verify account usability.

Command Description
sudo passwd username Set or reset user password
sudo passwd -l username Lock account password login
sudo passwd -u username Unlock account password login
sudo su - username Test login environment for new user
getent passwd username Confirm user entry in account database

Defaults and Safe Workflow

Check defaults first and validate each account creation.

Command Description
sudo useradd -D Show defaults (HOME, SHELL, SKEL, etc.)
sudo useradd -D -s /bin/bash Change default shell for future users
sudo useradd -m newuser && sudo passwd newuser Common two-step creation flow
sudo usermod -aG sudo newuser Grant admin privileges after creation
sudo userdel -r username Remove user and home directory when deprovisioning

Troubleshooting

Quick checks for common useradd errors.

Issue Check
useradd: user 'name' already exists Confirm with id name or choose a different username
group 'name' does not exist Create group first with groupadd or use an existing group
Home directory not created Use -m and verify defaults with useradd -D
Cannot log in after creation Check shell (getent passwd user) and set password with passwd
UID conflict Verify used UIDs in /etc/passwd before assigning -u manually

Related Guides

Use these guides for full account lifecycle tasks.

Guide Description
How to Create Users in Linux Using the useradd Command Full useradd tutorial with examples
usermod Command in Linux Modify existing user accounts
How to Delete Users in Linux Using userdel Remove users safely
How to Add User to Group in Linux Manage supplementary groups
How to Change User Password in Linux Set and rotate account passwords

How to Install Git on Debian 13

Git is the world’s most popular distributed version control system used by many open-source and commercial projects. It allows you to collaborate on projects with fellow developers, keep track of your code changes, revert to previous stages, create branches , and more.

This guide covers installing and configuring Git on Debian 13 (Trixie) using apt or by compiling from source.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Install Git (apt) sudo apt install git
Check Git version git --version
Set username git config --global user.name "Your Name"
Set email git config --global user.email "you@example.com"
View config git config --list

Installing Git with Apt

This is the quickest way to install Git on Debian.

Check if Git is already installed:

Terminal
git --version

If Git is not installed, you will see a “command not found” message. Otherwise, it shows the installed version.

Use the apt package manager to install Git:

Terminal
sudo apt update
sudo apt install git

Verify the installation:

Terminal
git --version

Debian 13 stable currently provides Git 2.47.3:

output
git version 2.47.3

You can now start configuring Git.

When a new version of Git is released, you can update using sudo apt update && sudo apt upgrade.

Installing Git from Source

The main benefit of installing Git from source is that you can compile any version you want. However, you cannot maintain your installation through the apt package manager.

Install the build dependencies:

Terminal
sudo apt update
sudo apt install libcurl4-gnutls-dev libexpat1-dev cmake gettext libz-dev libssl-dev gcc wget

Visit the Git download page to find the latest version.

At the time of writing, the latest stable Git version is 2.53.0.

If you need a different version, visit the Git archive to find available releases.

Download and extract the source to /usr/src:

Terminal
wget -c https://mirrors.edge.kernel.org/pub/software/scm/git/git-2.53.0.tar.gz -O - | sudo tar -xz -C /usr/src

Navigate to the source directory and compile:

Terminal
cd /usr/src/git-*
sudo make prefix=/usr/local all
sudo make prefix=/usr/local install

The compilation may take some time depending on your system.

If your shell still resolves /usr/bin/git after installation, open a new terminal or verify your PATH and binary location with:

Terminal
which git
echo $PATH

Verify the installation:

Terminal
git --version
output
git version 2.53.0

To upgrade to a newer version later, repeat the same process with the new version number.

Configuring Git

After installing Git, configure your username and email address. Git associates your identity with every commit you make.

Set your global commit name and email:

Terminal
git config --global user.name "Your Name"
git config --global user.email "youremail@yourdomain.com"

Verify the configuration:

Terminal
git config --list
output
user.name=Your Name
user.email=youremail@yourdomain.com

The configuration is stored in ~/.gitconfig:

~/.gitconfigconf
[user]
name = Your Name
email = youremail@yourdomain.com

You can edit the configuration using the git config command or by editing ~/.gitconfig directly.

For a deeper walkthrough, see How to Configure Git Username and Email .

Troubleshooting

E: Unable to locate package git
Run sudo apt update first and verify you are on Debian 13 repositories. If sources were recently changed, refresh package metadata again.

git --version still shows an older version after source install
Your shell may still resolve /usr/bin/git before /usr/local/bin/git. Check with which git and adjust PATH order if needed.

Build fails with missing headers or libraries
One or more dependencies are missing. Re-run the dependency install command and then compile again.

make succeeds but git command is not found
Confirm install step ran successfully: sudo make prefix=/usr/local install. Then check /usr/local/bin/git exists.

FAQ

Should you use apt or source on Debian 13?
For most systems, use apt because updates are integrated with Debian security and package management. Build from source only when you need a newer Git release than the repository version.

Does compiling from source replace the apt package automatically?
No. Source builds under /usr/local and can coexist with the apt package in /usr/bin. Your PATH order determines which binary runs by default.

How can you remove a source-installed Git version?
If you built from the source tree, run sudo make prefix=/usr/local uninstall from that same source directory.

Conclusion

We covered two ways to install Git on Debian 13: using apt, which provides Git 2.47.3, or compiling from source for the latest version. The default repository version is sufficient for most use cases.

For more information, see the Pro Git book .

React性能优化的完整方法论,附赠大厂面试通关技巧

作者 小时前端
2026年3月6日 18:18

开篇语

在前端面试中,React性能优化是一个绕不开的话题。无论是初级还是高级岗位,面试官总会问:"你做过哪些React性能优化?"、"如何定位性能问题?"、"React.memo和useCallback有什么区别?"

很多同学面对这些问题时,只能零散地背几个API,缺乏系统性的思路。今天这篇文章,我将结合一线开发经验,带你建立完整的React性能优化知识体系,让你从"知道几个优化技巧"到"能够系统性解决性能问题"。

性能优化的核心思路

建立性能问题的感知能力

很多开发者都是在用户投诉"页面卡"时才开始关注性能,其实性能优化应该是一个主动的过程。我总结了几个常见的性能问题信号:

  • 首屏加载超过3秒 - 用户开始失去耐心
  • 滚动时出现掉帧 - 肉眼可见的卡顿
  • 点击按钮响应延迟 - 交互体验差

当你发现这些现象时,就要考虑进行性能优化了。

系统性的优化框架

我总结了一个"三步走"的性能优化框架:

graph LR
    A[发现问题] --> B[定位原因] --> C[制定方案] --> D[验证效果]
    B --> E[工具分析]
    C --> F[选择策略]
    D --> G[数据对比]
  1. 发现问题:用户体验角度识别性能问题
  2. 定位原因:使用专业工具分析具体瓶颈
  3. 制定方案:针对不同问题选择合适优化策略
  4. 验证效果:通过数据验证优化效果

性能调试工具全解析

React DevTools Profiler - 组件级性能分析

React DevTools Profiler是我最常用的性能分析工具,它能精确记录组件的渲染时间、重渲染原因和Props变化。

实战案例:电商商品列表优化

有一次优化电商商品列表页面,用户反馈滚动时明显卡顿。我用Profiler录制了滚动操作:

  1. 打开React DevTools,切换到Profiler面板
  2. 点击录制按钮,模拟用户滚动操作
  3. 停止录制,查看火焰图

发现每次滚动都会触发所有商品卡片的重新渲染,即使大部分商品数据并没有变化。通过查看渲染原因,发现是因为父组件传递了内联函数导致引用变化。

优化方案:

// 优化前 - 每次渲染都创建新函数
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={() => addToCart(product.id)} // 内联函数
        />
      ))}
    </div>
  );
};

// 优化后 - 使用useCallback稳定函数引用
const ProductList = ({ products }) => {
  const handleAddToCart = useCallback((productId) => {
    addToCart(productId);
  }, []); // 空依赖数组,函数不会重新创建

  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
        />
      ))}
    </div>
  );
};

优化后,Profiler显示渲染时间从25ms降低到8ms,滚动FPS从45提升到58,用户体验明显改善。

Chrome DevTools Performance - 浏览器级性能分析

当Profiler显示组件渲染正常,但用户仍然反馈卡顿时,就需要从浏览器层面进行分析。

实战技巧:

  1. 录制用户操作流程
  2. 关注Main线程的黄色长任务块
  3. 查看Frames部分的帧率表现
  4. 分析Layout和Paint的开销

案例分析:搜索功能优化

搜索框输入时页面卡顿,Performance面板显示:

  • 每次输入都有超过100ms的黄色长任务
  • 大量的Layout和Paint操作
  • 帧率经常低于30fps

通过分析发现是因为搜索建议列表的DOM操作过于频繁。优化方案:

  • 使用防抖函数减少搜索触发频率
  • 实现虚拟滚动,只渲染可见的搜索建议
  • 缓存搜索结果,避免重复计算

其他实用工具推荐

  • why-did-you-render:监控不必要的重渲染
  • webpack-bundle-analyzer:分析打包体积
  • Lighthouse:整体性能评分和建议
  • Web Vitals:监控核心用户体验指标

React性能优化的四大策略

策略一:缓存优化 - 减少不必要的计算

React提供了三个核心的缓存API,合理使用能解决80%的性能问题。

1. React.memo - 组件级缓存

适用场景:组件props没有变化但仍然频繁重渲染

// 商品卡片组件 - 纯展示组件
const ProductCard = React.memo(({ product, onAddToCart }) => {
  console.log('ProductCard render:', product.name);
  
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>价格:¥{product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>
        加入购物车
      </button>
    </div>
  );
}, (prevProps, nextProps) => {
  // 自定义比较函数(可选)
  return prevProps.product.id === nextProps.product.id && 
         prevProps.product.price === nextProps.product.price;
});

// 父组件中使用
const ProductList = ({ products }) => {
  const [cartCount, setCartCount] = useState(0);
  
  // 注意:使用useCallback避免函数引用变化
  const handleAddToCart = useCallback((productId) => {
    console.log('添加商品到购物车:', productId);
    setCartCount(prev => prev + 1);
  }, []); // 空依赖数组,函数引用保持稳定

  return (
    <div>
      <h2>商品列表 (购物车:{cartCount}件)</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}
          />
        ))}
      </div>
    </div>
  );
};

实战技巧:

  • 给React.memo提供自定义比较函数时,要确保比较逻辑正确
  • 避免在props中传递对象或数组字面量,会导致比较失败
  • 配合useCallback和useMemo使用效果更佳

2. useMemo - 计算结果缓存

适用场景:复杂计算、数据转换、过滤排序等操作

// 商品列表过滤和排序
const ProductList = ({ products, filter, sortBy }) => {
  // 复杂的数据处理逻辑
  const processedProducts = useMemo(() => {
    console.log('重新计算商品列表');
    
    // 1. 过滤商品
    let filtered = products.filter(product => {
      if (filter.category && product.category !== filter.category) return false;
      if (filter.minPrice && product.price < filter.minPrice) return false;
      if (filter.maxPrice && product.price > filter.maxPrice) return false;
      return true;
    });
    
    // 2. 排序
    filtered.sort((a, b) => {
      switch (sortBy) {
        case 'price-asc':
          return a.price - b.price;
        case 'price-desc':
          return b.price - a.price;
        case 'name':
          return a.name.localeCompare(b.name);
        default:
          return 0;
      }
    });
    
    return filtered;
  }, [products, filter, sortBy]); // 只有这些依赖变化时才重新计算

  return (
    <div>
      <h2>商品列表 ({processedProducts.length}件)</h2>
      <div className="product-grid">
        {processedProducts.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

3. useCallback - 函数引用缓存

适用场景:将函数作为props传递给子组件时

// 优化的表单组件
const SearchForm = ({ onSearch }) => {
  const [keyword, setKeyword] = useState('');
  const [category, setCategory] = useState('');
  
  // 搜索函数 - 使用useCallback缓存
  const handleSearch = useCallback(() => {
    onSearch({
      keyword,
      category,
      timestamp: Date.now()
    });
  }, [keyword, category, onSearch]);
  
  // 重置函数 - 使用useCallback缓存
  const handleReset = useCallback(() => {
    setKeyword('');
    setCategory('');
    onSearch({
      keyword: '',
      category: '',
      timestamp: Date.now()
    });
  }, [onSearch]);
  
  return (
    <div className="search-form">
      <input 
        type="text" 
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="请输入关键词"
      />
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="">全部分类</option>
        <option value="electronics">电子产品</option>
        <option value="clothing">服装</option>
        <option value="books">图书</option>
      </select>
      <button onClick={handleSearch}>搜索</button>
      <button onClick={handleReset}>重置</button>
    </div>
  );
};

策略二:架构优化 - 合理拆分组件

良好的组件架构设计能从根源上减少性能问题。

状态就近原则

把状态放在最靠近使用它的组件中,避免不必要的状态提升。

// 不好的设计 - 状态过度提升
const Parent = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 展开状态没必要放在这里
  
  return (
    <div>
      <Child isExpanded={isExpanded} setIsExpanded={setIsExpanded} />
    </div>
  );
};

// 好的设计 - 状态就近管理
const Parent = () => {
  return (
    <div>
      <Child />
    </div>
  );
};

const Child = () => {
  const [isExpanded, setIsExpanded] = useState(false); // 状态放在使用它的组件中
  
  return (
    <div>
      <button onClick={() => setIsExpanded(!isExpanded)}>
        {isExpanded ? '收起' : '展开'}
      </button>
      {isExpanded && <div>展开的内容</div>}
    </div>
  );
};

按更新频率拆分组件

不同部分的更新频率不同,应该拆分成独立组件。

// 商品详情页 - 按更新频率拆分
const ProductDetail = ({ productId }) => {
  return (
    <div className="product-detail">
      {/* 基本信息 - 基本不变 */}
      <ProductBasicInfo productId={productId} />
      
      {/* 价格信息 - 可能促销变化 */}
      <ProductPrice productId={productId} />
      
      {/* 库存信息 - 实时变化 */}
      <ProductStock productId={productId} />
      
      {/* 用户评论 - 实时更新 */}
      <ProductReviews productId={productId} />
      
      {/* 相关推荐 - 根据算法变化 */}
      <ProductRecommendations productId={productId} />
    </div>
  );
};

策略三:列表优化 - 大数据量处理

虚拟滚动技术

当列表数据量很大时(超过1000条),虚拟滚动是必须的技术。

// 使用react-window实现虚拟滚动
import { FixedSizeList as List } from 'react-window';

const LargeProductList = ({ products }) => {
  // 只渲染可见区域的产品
  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductCard product={products[index]} />
    </div>
  );

  return (
    <List
      height={600} // 可视区域高度
      itemCount={products.length} // 总数据量
      itemSize={120} // 每行高度
      width="100%"
    >
      {Row}
    </List>
  );
};

key的正确使用

key的选择对列表性能影响很大。

// 不好的做法 - 使用索引作为key
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map((product, index) => (
        <ProductCard key={index} product={product} /> // 不要用index
      ))}
    </div>
  );
};

// 好的做法 - 使用稳定的唯一标识
const ProductList = ({ products }) => {
  return (
    <div>
      {products.map(product => (
        <ProductCard key={product.id} product={product} /> // 使用稳定的id
      ))}
    </div>
  );
};

策略四:代码分割 - 按需加载

React.lazy和Suspense

// 路由级别的代码分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

// 懒加载页面组件
const Home = React.lazy(() => import('./pages/Home'));
const ProductList = React.lazy(() => import('./pages/ProductList'));
const ProductDetail = React.lazy(() => import('./pages/ProductDetail'));
const ShoppingCart = React.lazy(() => import('./pages/ShoppingCart'));

const App = () => {
  return (
    <Router>
      <React.Suspense fallback={<div>加载中...</div>}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route path="/products" component={ProductList} />
          <Route path="/product/:id" component={ProductDetail} />
          <Route path="/cart" component={ShoppingCart} />
        </Switch>
      </React.Suspense>
    </Router>
  );
};

组件级别的代码分割

// 重型组件的按需加载
const HeavyChartComponent = React.lazy(() => 
  import('./components/HeavyChartComponent')
);

const Dashboard = () => {
  const [showChart, setShowChart] = useState(false);
  
  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        显示图表
      </button>
      
      {showChart && (
        <React.Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChartComponent />
        </React.Suspense>
      )}
    </div>
  );
};

性能优化的数据验证

性能优化不能凭感觉,必须用数据说话。

优化前后的数据对比

案例:电商首页优化

优化前:

  • 首屏加载时间:4.2秒
  • 组件平均渲染时间:25ms
  • 滚动FPS:平均45
  • 用户跳出率:35%

优化后:

  • 首屏加载时间:2.1秒(提升50%)
  • 组件平均渲染时间:8ms(提升68%)
  • 滚动FPS:平均58(提升29%)
  • 用户跳出率:22%(降低37%)

核心性能指标监控

// Web Vitals监控
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';

const reportWebVitals = (metric) => {
  console.log(metric);
  // 发送到监控系统
  sendToAnalytics(metric);
};

// 监控核心指标
getCLS(reportWebVitals); // 累积布局偏移
getFID(reportWebVitals); // 首次输入延迟
getFCP(reportWebVitals); // 首次内容绘制
getLCP(reportWebVitals); // 最大内容绘制
getTTFB(reportWebVitals); // 首字节时间

面试中的答题技巧

系统化回答框架

当面试官问"你做过哪些React性能优化"时,不要零散地列举,而是按照系统化框架回答:

我的回答思路: "我通常会从三个层面进行React性能优化:渲染层面、架构层面和加载层面。

在渲染层面,我主要使用React.memo、useMemo、useCallback来减少不必要的重渲染。比如在[具体项目]中,通过React.memo优化商品列表,让渲染时间从25ms降到8ms。

在架构层面,我会合理拆分组件,遵循状态就近原则,按更新频率组织组件结构。比如将商品详情页拆分成基本信息、价格、库存、评论等独立组件。

在加载层面,我使用代码分割、懒加载、虚拟滚动等技术。比如对路由组件进行懒加载,将首屏包体积减少了40%。

同时,我建立了性能监控机制,使用React DevTools Profiler和Chrome Performance面板定期分析性能,确保优化效果可量化。"

展现技术深度

面试官追问:"为什么React.memo能够优化性能?"

深度回答: "React.memo的原理涉及React的协调算法。当组件的props或state变化时,React会启动协调过程,通过Diff算法比较新旧虚拟DOM树的差异。

React.memo在这个过程中起到短路作用。它在组件更新阶段就执行浅比较,如果props没有变化,就直接跳过该组件及其子树的协调过程,避免了昂贵的虚拟DOM比较计算。

这种优化在组件树较深、子组件较多的情况下效果特别明显,因为React.memo阻断了变化向下传播的路径,让状态变化的影响局限在最小范围内。"

体现实战经验

面试官追问:"遇到过哪些具体的性能问题?"

实战案例: "在优化一个商品搜索页面时,用户反馈每输入一个字符都有明显卡顿。

通过Profiler分析发现,每次输入都会触发整个商品列表的重新渲染,包括过滤、排序等复杂计算。而且搜索建议列表的DOM更新也很频繁。

我的解决方案是:

  1. 使用防抖函数,将搜索触发频率控制在300ms一次
  2. 用useMemo缓存过滤排序后的结果
  3. 搜索建议列表实现虚拟滚动,只渲染可见项
  4. 用useCallback稳定事件处理函数

优化后,输入响应时间从500ms降到50ms,用户体验大幅提升。这个案例让我意识到性能优化要从用户操作路径出发,系统性地解决各个环节的性能瓶颈。"

总结与进阶

React性能优化是一个需要持续学习和实践的领域。记住这些核心要点:

  1. 建立性能意识:主动发现问题,而不是被动等待用户反馈
  2. 掌握系统方法:从渲染、架构、加载三个层面综合考虑
  3. 善用工具分析:Profiler、Performance面板等工具是定位问题的利器
  4. 数据驱动优化:用数据验证优化效果,避免凭感觉
  5. 持续监控改进:性能优化是一个持续的过程,不是一次性的工作

技术前沿关注

  • React 18的Concurrent Features:为性能优化带来新的可能性
  • Server Components:减少客户端计算和包体积
  • Edge Computing:结合边缘计算优化加载性能
  • Micro-frontend:微前端架构下的性能优化策略

性能优化不仅仅是技术问题,更是用户体验和业务价值的体现。掌握这些技能,不仅能让你在面试中脱颖而出,更能让你在实际工作中创造真正的价值。

希望这篇文章能帮助你建立完整的React性能优化知识体系。记住,最好的优化是预防,在开发过程中就考虑性能因素,而不是等问题出现后再补救。

你觉得这篇文章对你有帮助吗?欢迎在评论区分享你的性能优化经验和问题!

延伸阅读:

Jetpack Compose BOM 2026.02.01 解读与升级指南

作者 Nicko
2026年3月6日 17:47

Jetpack Compose BOM 2026.02.01 深度解析:这次升级到底更新了什么,值不值得跟?

如果你最近准备把项目里的 Compose 依赖统一升到 androidx.compose:compose-bom:2026.02.01,先说结论:这不是一波“新能力井喷”的大版本 BOM,而是一版很典型的稳定性补丁 BOM。它的核心价值不在于一次性塞进大量新 API,而在于把 Compose 1.10 这一代已经落地的新能力,用一组更稳的 patch 版本重新打包给你。

对于线上项目来说,这种版本往往比“看起来更炫”的版本更值得重视:新功能不一定是每天都能用上的,但修掉布局回归、补齐运行时和 UI 侧补丁、把 BOM 对齐到更稳定的一组 artifact,通常直接影响的是发布风险和排障成本。

本文基于 Android 官方文档、AndroidX release notes、Android Developers Blog 等一手资料整理,重点回答 4 个问题:

  1. 2026.02.01 到底映射到了哪些 Compose 版本;
  2. 它相对上一版 2026.02.00 具体变了什么;
  3. 这些变化对实际工程能力提升在哪里;
  4. 项目升级时有哪些容易踩的适配点。

先说结论

  • Compose BOM 2026.02.01 的主变化,是把 Compose 核心模块族从 1.10.3 对齐到 1.10.4
  • material3 在这版 BOM 里没有继续升级,仍然是 1.4.0
  • material3-adaptive 这条线也没有变,仍然是 1.2.0
  • 从官方 release notes 来看,foundation 1.10.4 明确修了一个布局定位回归;runtime/ui/material/animation 1.10.4 没有列出新的公开 API 变化,更像一组维护性 patch。这是基于官方 changelog 颗粒度做出的工程判断,不是 Google 原文的直接表述。
  • 如果你此前已经上了 2026.02.00,这次升级的重点是“补丁”和“稳定性”;如果你还停在更早的 2025 年末版本,那你实际上会连同 Compose 1.10、Material3 1.4 这一波能力一起吃进来。

这版 BOM 映射到了什么

Compose BOM 的职责,是帮你把一组 Compose 相关 artifact 锁到一套经过官方验证的兼容版本组合里。它不包含 Compose Compiler,这一点在官方 BOM 文档里说得很明确。

结合官方 BOM Mapping 页面,2026.02.01 可以概括为下面这张表:

模块族 2026.02.01 2026.02.00 变化判断
animation 1.10.4 1.10.3 升级补丁
foundation 1.10.4 1.10.3 升级补丁
material 1.10.4 1.10.3 升级补丁
runtime 1.10.4 1.10.3 升级补丁
ui 1.10.4 1.10.3 升级补丁
material3 1.4.0 1.4.0 无变化
material3-adaptive 1.2.0 1.2.0 无变化

这里说的“模块族”,不只是单个 artifact,而是一整串同代模块,例如:

  • ui 族通常包含 uiui-graphicsui-textui-toolingui-test-junit4 等;
  • foundation 族通常包含 foundationfoundation-layout
  • runtime 族通常包含 runtimeruntime-livedataruntime-rxjava*
  • animation 族通常包含 animationanimation-coreanimation-graphics
  • material 指的是经典 Compose Material(M2)线;
  • material3material3-adaptive 维持独立节奏。

换句话说,这版 BOM 的核心动作其实很清楚:把 Compose 核心运行栈整体抬到 1.10.4,但不动 Material3 主版本。

相比 2026.02.00,具体更新了什么

1. foundation:修了一个真实会影响界面的定位问题

在官方 foundation release notes 里,1.10.4 明确提到修复了一个回归:

  • 当一个对齐类 modifier 被错误地用在它不属于的 scope 中时,之前版本可能会触发错误布局位置
  • 1.10.4 对这个问题做了修复。

这个改动看起来不“炫技”,但对线上项目非常重要。因为这类问题往往最麻烦:

  • 编译不报错;
  • 页面也不是全挂;
  • 只在特定组合布局、特定修饰链、特定设备尺寸下才出现“怎么这里偏了一点”的问题;
  • 最后调半天才发现不是业务代码逻辑,而是底层布局行为的回归。

如果你的项目里有比较多自定义布局、复杂 slot API、嵌套 Box / Column / Row / Lazy* 布局,再加上扩展 modifier 比较多,这个修复是有实际价值的。

2. runtime:官方写明“和 1.10.3 没有变化”

runtime 1.10.4 的官方 release notes 很直接:There are no changes in this release.

这意味着什么?

  • 如果你只看 runtime 模块本身,这次从 1.10.31.10.4 不会带来新的运行时语义变化;
  • 它更多是在 BOM 层面把整个 Compose 版本集重新对齐;
  • 对项目升级而言,runtime 不是这次最需要重点回归验证的模块。

3. ui / material / animation:更像同步补丁与维护性发布

从对应的 AndroidX release notes 页面来看:

  • ui 1.10.4 没有列出额外的显性 API 变更摘要;
  • material 1.10.4 也没有单独列出显著变更说明;
  • animation 1.10.4 在 release 页面上同样没有像大版本那样给出新的能力条目。

因此比较稳妥的工程判断是:这几个 1.10.4 更像一组维护性 patch / 同步发布,而不是新的功能迭代节点。

这里还是强调一下:这句话是基于官方 release notes 未列出新增 API / 新特性摘要的推断。如果你需要逐 commit 追踪,可以继续顺着各 release notes 页里的 change list 链接深挖,但就日常升级决策来说,这个粒度已经足够判断风险级别。

4. material3:这版 BOM 不动,仍然停在 1.4.0

这点很关键。很多同学看到 BOM 版本号继续往 2026.02.01 走,会下意识以为 Material3 也一起升了。实际上没有。

按官方映射页,这一版里:

  • androidx.compose.material3:material3 仍然是 1.4.0
  • androidx.compose.material3.adaptive:* 仍然是 1.2.0

所以,如果你这次升级之后感觉 Material3 API 没有新增,那不是你姿势不对,而是 BOM 本身就没有把 Material3 再往前推。

那“能力提升”体现在哪?

如果只盯着 2026.02.012026.02.00 的 diff,你会觉得“这不就是个 patch 吗”。这判断没错,但不完整。

更准确地说,这版 BOM 的价值分成两层:

  1. 短期价值:把核心 Compose 族对齐到更稳的 1.10.4
  2. 平台价值:把 Compose 1.10 和 Material3 1.4 这一代的能力,打包到一个更适合生产落地的组合上。

下面这部分,才是程序员真正关心的“升了以后我能干嘛”。

1. Lazy 布局和滚动链路更成熟了

根据 Android Developers Blog 对 Compose 1.10 的总结,Google 在这一代继续强化了性能路线,几个点很值得关注:

  • Pausable Composition 已默认用于 Lazy 布局的预取;
  • 官方给出的内部 benchmark 显示,某些滚动场景下 Compose 已能达到和传统 View 非常接近的性能水平;
  • 这意味着在复杂列表、卡片流、瀑布式 feed、首页混排场景里,Compose 的“流畅度心理门槛”又往前迈了一步。

对于业务开发来说,这种能力提升不是“我今天多了一个 API”,而是:

  • 你更敢把复杂首页从 RecyclerView 迁到 Compose;
  • 你在做首屏预取、异步图片、嵌套列表时,调优空间更大;
  • 同样一套 UI 结构,达到稳定 60fps / 120fps 的难度在下降。

2. 状态保存模型更细了:retain 填上了中间层

Compose 1.10 里新增了 retain API。官方的定位很明确:它位于 rememberrememberSaveable 之间。

这对大型页面尤其有意义。过去很多场景都很尴尬:

  • remember 生命周期太短,离开组合就没了;
  • rememberSaveable 又偏“跨配置变化/进程恢复”的重量级持久化路径;
  • 中间缺一个“离开当前组合树但暂时还想留住”的状态层。

retain 的意义就在这里。你可以把它理解为:为复杂导航栈、可回收页面、短时脱离组合的内容,提供一个更合理的状态存活层级。

如果你的项目里有这些模式,这项能力很实用:

  • 多 Tab 页面切换;
  • 底部导航多 back stack;
  • 局部内容被回收后重新挂载;
  • 大表单 / 编辑页中间状态不想轻易丢。

3. Shared Element / Lookahead 这一套更能上生产了

Compose 1.10 在动画侧最像“能力升级”的部分,是共享元素与 Lookahead 相关 API 的继续完善。官方博客提到的点包括:

  • 可以在运行时启用或禁用共享元素;
  • Modifier.skipToLookaheadPosition 让空间位置同步更灵活;
  • 过渡动画支持初始速度;
  • 支持 veiled shared bounds 这类更接近真实产品需求的过渡模式。

如果你做的是下面这些页面,这一代动画 API 的成熟度提升会比较明显:

  • 列表到详情页的跨屏转场;
  • 卡片展开 / 收起;
  • Hero image、视频封面、头像转场;
  • 大屏多面板布局里跨容器的视觉连贯动画。

而从 animation 1.10.4 只是跟进 patch 这一点也能看出,Google 这一阶段更像是在把 1.10 这一代能力往稳定期推进,而不是继续大开大合地扩 API。

4. Material3 1.4.0 的价值,很多人低估了

虽然 2026.02.01 没有继续升级 material3,但只要你项目还没吃上 1.4.0,这一版 BOM 仍然值得关注。

根据官方 material3 release notes 和 Android Developers Blog,1.4.0 这代比较有感的能力包括:

  • 基于 state-based TextField 的新文本输入体系;
  • SecureTextField / OutlinedSecureTextField,更适合密码、敏感信息输入;
  • TextautoSize 能力;
  • HorizontalCenteredHeroCarousel
  • TimePicker 在 picker / input 模式之间切换;
  • 新的纵向拖拽手柄(vertical drag handle);
  • 一批曾经实验性的 Material3 API 进入稳定状态;
  • 一些性能优化已经进入正式版。

这组能力对真实业务非常有价值:

  • 输入组件 更接近现代应用的复杂表单需求;
  • 安全输入 不再需要大量自定义封装;
  • 自适应排版 在标题、卡片、横幅类组件里更好落地;
  • 内容型首页 可以更方便地做 Hero Carousel 一类强运营模块;
  • 时间选择器 的可用性更好。

对工程侧的实际收益,可以怎么理解

如果只用一句话总结:2026.02.01 不是“让我今天马上能写出完全不同 UI”的版本,而是“让 Compose 这条生产链更稳、更敢大规模用”的版本。

把收益拆开看,会更清楚:

收益 1:列表、复杂布局和动画的生产风险更低

  • foundation 1.10.4 修正布局回归,直接降低界面错位风险;
  • 1.10 代的性能和动画能力已经明显更适合生产;
  • 对复杂首页、商城、内容流、社交 feed 这类页面更友好。

收益 2:状态管理更贴近大型应用架构

  • retain 提供了比 remember 更长、比 rememberSaveable 更轻的状态层级;
  • 对 Navigation、多 back stack、复杂编辑流尤其有帮助;
  • 可以减少很多“到底是存在 ViewModel 里还是存在 rememberSaveable 里”的别扭设计。

收益 3:输入与 Material3 组件更可用

  • 新 TextField 能力和 SecureTextField 让表单页收益明显;
  • autoSize 和 Carousel 等能力,让内容型设计更容易实现;
  • 如果你在推进 design system 的 Compose 化,1.4.0 是个比 1.3.x 更成熟的落点。

升级方式:依赖应该怎么写

最标准的写法还是直接上 BOM:

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2026.02.01")

    implementation(composeBom)
    androidTestImplementation(composeBom)

    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.foundation:foundation")
    implementation("androidx.compose.material3:material3")

    debugImplementation("androidx.compose.ui:ui-tooling")
    androidTestImplementation("androidx.compose.ui:ui-test-junit4")
}

如果你用 Version Catalog,也建议只在 catalog 里声明 BOM 版本,把具体 Compose artifact 写成无版本号依赖,避免手滑把单个模块拉出对齐范围。

升级适配要点:这些地方一定要看

1. 先记住:BOM 不管 Compose Compiler

这是很多项目升级时最容易搞混的点。

官方文档明确说明:Compose Compiler 不包含在 Compose BOM 里。

同时,官方 Compose Compiler 文档又补了一条很关键的信息:从 Kotlin 2.0 开始,Compose Compiler 由 Kotlin 仓库统一管理,推荐直接使用 Compose Compiler Gradle plugin,并让它和 Kotlin 版本保持一致。

也就是说:

  • compose-bom,不等于 compiler 自动一起升;
  • 如果你已经在 Kotlin 2.0+,优先走官方推荐的 org.jetbrains.kotlin.plugin.compose
  • 如果你还停在旧 Kotlin 版本,升级前要核对 compiler compatibility map,不要只盯着 BOM。

一个典型配置如下:

plugins {
    id("org.jetbrains.kotlin.android") version "<your-kotlin-version>"
    id("org.jetbrains.kotlin.plugin.compose") version "<your-kotlin-version>"
}

2. 如果你手动覆盖过单个 Compose 依赖,先清理一遍

BOM 的前提是“版本统一受控”。

如果你的项目历史上做过下面这些事:

  • 手写过 ui:1.x.x
  • 单独把 material3 钉在别的版本;
  • 因为临时修 bug 覆盖过 foundation-layout
  • 引入过 alpha / beta 版 Compose artifact;

那升级到 2026.02.01 前,最好先把这些 override 摸一遍。否则你以为自己在用 BOM,实际依赖图已经部分失控了。

建议直接跑一遍依赖树确认:

./gradlew :app:dependencies --configuration debugRuntimeClasspath

重点看有没有:

  • 同一模块族里混入多个小版本;
  • 稳定版和 alpha / beta 混用;
  • material3 被外部组件库偷偷带成别的版本。

3. 如果你从更早版本跨上来,要补测布局和滚动,不要只测编译通过

这次官方明确提到的补丁在 foundation,说明布局链路是本轮升级里最值得回归的地方。

建议重点回归这几类页面:

  • 大量自定义 modifier 的页面;
  • LazyColumn / LazyVerticalGrid / Pager 混合页面;
  • 有粘性头部、吸顶、嵌套滚动的页面;
  • 依赖对齐、权重、offset、padding 叠加的复杂布局;
  • 平板、折叠屏、横屏等大尺寸场景。

原因很简单:布局回归类问题最容易“编译通过、代码 review 也看不出来,但线上截图才暴露”。

4. 如果你准备用 Material3 新输入体系,优先从局部页面试点

material3 1.4.0 的 TextField 体系更新是很有价值的,但也意味着:

  • 你的 design system 可能要同步抽象;
  • 表单状态管理方式可能要调整;
  • 输入过滤、校验、错误态展示、焦点切换等逻辑可能要重新梳理。

我的建议不是“全量一把梭”,而是:

  • 先在登录、注册、个人资料编辑、收货地址这类表单集中页面试点;
  • 把状态模型、错误态规范、密码输入策略跑顺;
  • 再向更复杂的业务域扩散。

5. 如果你还在用 Material Icons 扩展库,顺手评估迁移到 Material Symbols

material3 官方 release notes 里,Google 已经不再推荐继续使用 Material Icons Library,而是建议使用 Material Symbols 的自动镜像 Vector Drawable 方案。

这不是一个“必须今天改”的阻塞项,但如果你刚好在做 design token、图标系统、资源瘦身或者国际化适配,这次升级很适合顺手把这件事纳入技术债清单。

6. 关注大版本背景:新发布 AndroidX 库的默认 minSdk 已经提高到 23

Google 在 Compose 1.10 相关说明里已经提到,AndroidX 的新发布库默认 minSdk 正在从 2123 提升。

对大多数今天仍在活跃开发的项目来说,这通常不是大问题;但如果你的投放范围、ROM 适配或者企业客户环境还覆盖 API 21/22,那升级前最好把下面几件事再确认一遍:

  • App 的实际 minSdk
  • 业务模块是否有单独的发布目标;
  • 第三方 SDK 是否还卡着低版本系统;
  • CI / 测试矩阵里是否保留了低版本设备。

这版值不值得升?我会怎么建议

适合尽快跟进的情况

  • 你已经在 Compose 1.10.x,想吃掉最新 patch;
  • 你最近碰到过列表、布局、定位错位类问题;
  • 你准备把更多复杂页面迁到 Compose;
  • 你想把 Material3 1.4.0 作为新的稳定落点。

这种场景下,2026.02.01 基本属于“可以排进近期升级计划”的版本。

可以稍微观察一下的情况

  • 你线上项目非常稳定,最近没有 Compose 相关问题;
  • 你已经在 2026.02.00 且没有布局异常;
  • 你对 material3 新能力没有迫切需求;
  • 你当前迭代窗口不允许 UI 侧做大量回归。

这种情况下,也不是不能升,而是可以把它当成“下一个常规基础设施升级窗口顺手做”的版本。

一个更实用的判断:这版 BOM 的真实定位是什么

如果让我用一句程序员视角的话来概括:

Jetpack Compose BOM 2026.02.01 本质上是 Compose 1.10 时代的一次稳定性收口版 BOM。

它没有把 Material3 再推到新的主版本,也没有甩出一串高调新 API;但它做了更重要的事:

  • 把核心 Compose 族统一补到 1.10.4
  • 修复了明确的布局定位回归;
  • 继续站在 1.10 的性能、状态、动画能力之上;
  • material3 1.4.0 这代组件能力,处在一个更适合生产项目接入的 BOM 组合里。

如果你问“要不要升”,我的答案会是:

  • 2026.02.00 升上来:更像一次低风险、偏稳健的补丁升级;
  • 从更老版本直接升上来:收益会明显大得多,但记得按 compiler + BOM + 布局回归 + 输入组件 四条线一起看。

参考资料

如果你后面还要继续升级,我建议下一步别只盯着 BOM 版本号,而是把三件事绑在一起看:

  1. Kotlin / Compose Compiler 是否同步;
  2. Material3 新输入体系是否准备接入;
  3. 复杂布局页是否有足够的回归覆盖。

这三件事,往往比“我是不是已经升到最新 BOM”更影响真实项目的收益。

nestjs学习 - 控制器、提供者、模块

作者 web_bee
2026年3月6日 17:37

一、控制器(Controller)

控制器负责处理传入的请求并向客户端返回响应

接收客户的请求,然后告诉 服务层该做什么,最后把结果端给客户。

对应请求路径的配置。

控制器不过多介绍,比较好理解,对应的规则可以查阅官方文档。

二、提供者(Provide)

提供者是Nest中的一个基本概念。

许多基本的Nest类可以被视为提供者 - 服务、存储库、工厂、助手等。提供者的主要思想是它可以作为依赖项注入

这意味着对象可以彼此之间建立各种关系,而对象实例的“连接”功能可以在很大程度上委托给Nest运行时系统。

1. 它是什么

提供者是普通的JavaScript类,在模块中声明为providers

Provider 是一个 可以被依赖注入系统 “提供”给其他类(通常是控制器 Controller 或其他服务 Service)的对象函数

当你在一个类(如 Controller)的构造函数中声明了一个依赖项时,NestJS 会去查找对应的 Provider 来注入这个依赖。

白话描述一:它是一个对象、值或函数,它可以通过 依赖注入 的方式,注入到系统中供其它类使用;

“Provider 是 NestJS IoC 容器管理的资源(可以是类实例、值或工厂函数),它作为依赖项,被自动注入到需要它的类中。”

2. 使用:

使用提供者通常分为三个步骤:定义注册注入

2.1 定义

通常使用 @Injectable() 装饰器标记一个类。

import { Injectable } from '@nestjs/common';

@Injectable() // 这个装饰器告诉 NestJS,CatService 是一个提供者
export class CatService {
  private readonly cats = [];

  create(cat: any) {
    this.cats.push(cat);
  }

  findAll(): any[] {
    return this.cats;
  }
}

2.2 注册

必须在模块(Module)的 providers 数组中注册该提供者,这样它才能被该模块内的其他类发现。

// cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatService } from './cat.service';

@Module({
  controllers: [CatsController],
  providers: [CatService], // 在这里注册
})
export class CatsModule {}

2.3 注入

在构造函数中声明依赖,NestJS 会自动解析类型并注入实例。

// cats.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CatService } from './cat.service';

@Controller('cats')
export class CatsController {
  // 通过构造函数注入
  constructor(
    private readonly catService: CatService
  ) {}

  @Post()
  create(@Body() createCatDto: any) {
    this.catService.create(createCatDto);
    return 'Action has been executed';
  }

  @Get()
  findAll() {
    return this.catService.findAll();
  }
}

2.4 自定义提供者

除了标准的类提供者,NestJS 还支持更灵活的自定义提供者,用于处理复杂的场景。

定义、注册过程
const configServiceProvider = {
  provide: ConfigService,
  useClass:
    process.env.NODE_ENV === 'development'
      ? DevelopmentConfigService
      : ProductionConfigService,
};

@Injectable()
class LoggerService {
  /* implementation details */
}

@Module({
  providers: [
    // 一、普通提供者,或称为:类提供者
    configServiceProvider,
    // 二、值提供者示例
    {
      provide: 'AA',
      useValue: {
        a: 1,
        b: 2,
      },
    },
    // 三、工厂提供者示例:动态创建数据库配置
    {
      provide: 'DB_CONFIG',
      useFactory: (usersService: UsersService) => {
        // 工厂函数可以执行异步操作、条件判断等
        const env = process.env.NODE_ENV || 'development';
        return {
          host: env === 'production' ? 'prod.db.com' : 'localhost',
          port: 5432,
          database: 'users_db',
          timestamp: new Date().toISOString(),
          // 可以使用注入的依赖
          serviceName: usersService.constructor.name,
        };
      },
      inject: [UsersService], // 声明依赖项
    },
    // 四、异步工厂提供者示例
    {
      provide: 'ASYNC_CONNECTION',
      useFactory: async () => {
        // 模拟异步操作,如数据库连接
        await new Promise(resolve => setTimeout(resolve, 8000));
        return {
          status: 'connected',
          connectionId: Math.random().toString(36).substring(7),
          connectedAt: new Date().toISOString(),
        };
      },
    },
    
    // 别名提供者: useExisting 
    LoggerService,
    {
      provide: 'AliasedLoggerService',
      useExisting: LoggerService,
    },
    
  ],
})
注入方式:
@Controller('users')
export class UsersController {
  constructor(
    @Inject('AA') private readonly aa: any,
    @Inject('DB_CONFIG') private readonly dbConfig: any,
    @Inject('ASYNC_CONNECTION') private readonly connection: any,
  ) {
    // 在构造函数中可以看到工厂提供者创建的实例
    console.log('数据库配置:', this.dbConfig);
    console.log('异步连接:', this.connection);
  }
}

三、模块(module)

在 NestJS 框架中,@Module() 装饰器是构建应用程序架构的基石。NestJS 的整个应用结构就是由一个个模块组成的树状结构。

以下是对 @Module() 的详细介绍,包括它的定义、作用、核心属性以及使用示例。

1. 它是什么

@Module() 是一个 TypeScript 装饰器(Decorator),用于定义一个 NestJS 模块

在 NestJS 中,模块是组织代码的基本单元。每个 NestJS 应用程序至少有一个模块(通常是 AppModule),作为应用程序的根模块。通过模块,NestJS 能够利用依赖注入(Dependency Injection)系统来管理类之间的依赖关系。

2. 它的作用是什么?

@Module() 的主要作用是定义上下文边界组织依赖关系。它告诉 NestJS 编译器:

  • 这个模块包含哪些提供者(Services/Providers)。
  • 这个模块向外暴露哪些提供者供其他模块使用。
  • 这个模块需要导入哪些其他模块。
  • 这个模块包含哪些控制器(Controllers)。

简单来说,它解决了代码的高内聚、低耦合问题,让大型项目的结构清晰可见。

3. 它的核心属性

@Module() 装饰器接收一个配置对象,该对象包含四个主要属性:

属性 类型 描述
providers Provider[] 提供者数组。这里定义的服务(Service)、仓库(Repository)或其他可注入的类,将在当前模块内通过依赖注入可用。
exports Provider[] 导出数组。默认情况下,模块内的提供者只能在模块内部使用。如果想让其他模块也能使用这些提供者,必须在这里列出它们。
imports Module[] 导入数组。列出当前模块所依赖的其他模块。导入后,当前模块可以使用那些被导出模块中的提供者。
controllers Controller[] 控制器数组。定义属于该模块的控制器,用于处理 HTTP 请求并返回响应。

4. 使用

假设我们要构建一个简单的用户管理系统,包含 UsersModule(用户模块)和 DatabaseModule(数据库模块)。

场景 A:定义一个基础模块 (UsersModule)

// users/users.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class UsersService {
  findAll() {
    return [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  }
}

// users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  // 依赖注入 UsersService
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }
}

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  // 1. 注册控制器,处理 /users 路由
  controllers: [UsersController],
  
  // 2. 注册服务,使其可在本模块内注入
  providers: [UsersService],
  
  // 3. 如果其他模块想用 UsersService,需在此导出
  exports: [UsersService], 
  
  // 4. 如果依赖其他模块(如数据库),在此导入
  imports: [], 
})
export class UsersModule {}

场景 B:模块间的依赖 (导入与导出)

假设我们有一个 DatabaseModule 提供了一个 DbConnection 服务,并且我们希望 UsersModule 能使用它。

// database/database.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class DbConnection {
  connect() {
    console.log('Connected to DB');
  }
}

// database/database.module.ts
import { Module } from '@nestjs/common';
import { DbConnection } from './database.service';

@Module({
  providers: [DbConnection],
  // 关键点:必须导出,其他模块才能用
  exports: [DbConnection], 
})
export class DatabaseModule {}

现在,修改 UsersModule 来使用它:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { DatabaseModule } from '../database/database.module'; // 引入模块定义

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
  // 关键点:导入 DatabaseModule,这样 UsersService 就可以注入 DbConnection 了
  imports: [DatabaseModule], 
})
export class UsersModule {}

users.service.ts

// users/users.service.ts (更新后)
import { Injectable } from '@nestjs/common';
import { DbConnection } from '../database/database.service';

@Injectable()
export class UsersService {
  // 现在可以注入了,因为 UsersModule 导入了 DatabaseModule 且后者导出了 DbConnection
  constructor(private readonly db: DbConnection) {
    this.db.connect();
  }
  
  findAll() {
    return [{ id: 1, name: 'Alice' }];
  }
}

5. 最佳实践与注意事项

  1. 根模块 (Root Module) :每个应用都有一个根模块(通常在 app.module.ts),它是模块树的入口。NestJS 从根模块开始解析依赖图。
  2. 不要循环依赖:尽量避免模块 A 导入模块 B,同时模块 B 又导入模块 A。如果必须这样做,需要使用 forwardRef(() => ModuleB)
  3. 按需导出:只导出其他模块真正需要的提供者。这有助于隐藏内部实现细节,保持封装性。
  4. 功能内聚:一个模块应该专注于单一的业务领域(例如:用户模块只处理用户相关逻辑,订单模块只处理订单逻辑)。
  5. 全局模块:如果一个模块(如配置模块、日志模块)需要在几乎所有地方使用,可以使用 @Global() 装饰器将其标记为全局模块,这样就不需要在每个模块的 imports 数组中重复导入它。

6. 总结

@Module() 是 NestJS 的骨架。

  • Controllers 处理请求。
  • Providers 处理业务逻辑。
  • Imports/Exports 处理模块间的通信。
  • @Module() 将它们捆绑在一起,形成一个有机的整体。

基于腾讯地图实现电子围栏绘制与校验

2026年3月6日 17:32

需求背景:在安全巡检系统中,为巡检人员配置“电子围栏”,当人员在围栏内(或异常停留超时)触发告警。业务需要一个可配置、可编辑、可校验的围栏编辑器,支持多边形/矩形绘制、相交检测、搜索定位、缩略图生成上传和启停状态设置。

image.png

1. 组件背景与业务场景

  • 业务目标:为巡检系统配置“电子围栏”,限定巡检活动区域,配合异常停留时限与启停状态形成完整的策略。
  • 使用人群:业务管理员/调度人员;交互上要求“易绘制、可编辑、易清空、可搜索定位”。
  • 数据形态:围栏区域以坐标序列存储(多边形/矩形路径),序列化为 JSON 持久化到后端。
  • 辅助要素:提交前需校验围栏是否相交,生成围栏缩略图用于列表/详情展示。

界面入口为对话框模式(Dialog),包含基础表单与地图绘制区:

  • 围栏区域名称、异常停留时限(分钟)、启停状态;
  • 地图区域提供绘制/编辑/删除/一键删除、形状切换(多边形/矩形)、地点搜索。

image.png


2. 核心功能点与交互流程拆解

  • 模式切换:绘制模式(DRAW)/编辑模式(INTERACT)/删除单个/一键删除全部。
  • 工具切换:多边形与矩形两类覆盖物的快速切换。
  • 搜索定位:联想输入+节流调用,点击候选项在地图上定位并弹出信息窗。
  • 坐标收集:监听绘制与编辑完成事件,实时收集 polygon/rectangle 的路径点,序列化到表单字段 fenceArea。
  • 相交检测:提交前对所有区域两两进行相交判断,避免配置出重叠区域。
  • 缩略图生成:使用 Canvas 将围栏几何映射到可视缩略图,上传并记录返回的 URL。
  • 资源清理:组件卸载时销毁编辑器与地图实例,释放内存。

基本链路如下:

  1. 打开弹窗 → 根据类型(新建/编辑/查看)设置标题与编辑模式
  2. 初始化地图与编辑器 → 注入已有几何 → 绑定 draw/adjust 完成事件
  3. 绘制/编辑过程中更新 fenceArea → 搜索定位辅助操作
  4. 提交:停止编辑器 → 收集/校验坐标 → 生成并上传缩略图 → 调用创建/更新接口

3. 技术选型与实现要点

3.1 地图与几何编辑:TMap GeometryEditor

  • 地图基座:TMap.Map
  • 覆盖物:TMap.MultiPolygon(多边形) 与 TMap.MultiRectangle(矩形)
  • 编辑器:TMap.tools.GeometryEditor,支持 actionMode(激活模式)、activeOverlay(激活覆盖物)、snappable/selectable 等配置
  • 事件监听:draw_complete(绘制完成)、adjust_complete(编辑完成)

示例代码initMap:

const initMap = () => {
  map = new TMap.Map("map-container", {
    zoom: 16,
    center: new TMap.LatLng(latitude.value, longitude.value),
    showControl: false,
  });

  // 已有几何解析与注入(编辑/查看)
  const polygonGeometries: any[] = [];
  if ((formType.value === "update" || formType.value === "view") && formData.value.fenceArea) {
    const geometries = JSON.parse(formData.value.fenceArea);
    geometries.forEach((geo) => {
      polygonGeometries.push({
        id: `polygon_${polygonGeometries.length}`,
        paths: geo.paths.map((p) => new TMap.LatLng(p.lat, p.lng)),
      });
    });
  }

  // 多边形与矩形覆盖物
  polygon = new TMap.MultiPolygon({ map, geometries: polygonGeometries });
  rectangle = new TMap.MultiRectangle({ map, geometries: [] });

  // 编辑器绑定
  editor = new TMap.tools.GeometryEditor({
    map,
    overlayList: [
      { overlay: polygon, id: "polygon", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
      { overlay: rectangle, id: "rectangle", styles: { highlight: new TMap.PolygonStyle({ color: "rgba(255,255,0,.6)" }) }, selectedStyleId: "highlight" },
    ],
    actionMode: "", // 由外部模式切换驱动
    activeOverlayId: activeType.value,
    snappable: !isViewMode.value,
    selectable: !isViewMode.value,
  });

  // 绘制/编辑完成后更新数据
  editor.on("draw_complete", updateFenceArea);
  editor.on("adjust_complete", updateFenceArea);
};

模式切换实现(绘制/编辑/删除/一键删除):

const handleModeChange = (id: "draw"|"edit"|"delete"|"deletes") => {
  if (activeMode.value === id && id !== "delete" && id !== "deletes") return;

  switch (id) {
    case "draw":
      editor.stop();
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      activeMode.value = id;
      break;
    case "edit":
      editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      activeMode.value = id;
      break;
    case "delete":
      editor.delete();
      updateFenceArea();
      break;
    case "deletes":
      // 临时切换到编辑模式,批量选择并删除所有几何
      const wasInDrawMode = activeMode.value === "draw";
      if (wasInDrawMode) {
        activeMode.value = "edit";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT);
      }
      editor.select([]);
      const polygonIds = polygon?.geometries?.map((g) => g.id) || [];
      const rectIds = rectangle?.geometries?.map((g) => g.id) || [];
      if (polygonIds.length) { editor.setActiveOverlay("polygon"); editor.select(polygonIds); editor.delete(); }
      if (rectIds.length) { editor.setActiveOverlay("rectangle"); editor.select(rectIds); editor.delete(); }
      updateFenceArea();
      if (wasInDrawMode) {
        activeMode.value = "draw";
        editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW);
      }
      break;
  }
};

工具切换(多边形/矩形)仅需切换 activeOverlayId:

const handleToolChange = (id: "polygon"|"rectangle") => {
  if (activeType.value === id) return;
  activeType.value = id;
  editor.setActiveOverlay(id);
};

3.2 坐标收集与相交检测

  • 目标:统一收集 polygon/rectangle 的路径坐标,序列化为字符串到 fenceArea

  • 相交检测:两两比较所有多边形路径,借助 TMap.geometry.computePolygonIntersection 判断是否相交,若相交阻断提交

const updateFenceArea = () => {
  const geometries: any[] = [];
  const allPolygons: any[] = [];

  if (polygon?.geometries?.length) {
    polygon.geometries.forEach((geo) => {
      geometries.push({ type: "polygon", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }
  if (rectangle?.geometries?.length) {
    rectangle.geometries.forEach((geo) => {
      geometries.push({ type: "rectangle", paths: geo.paths });
      allPolygons.push(geo.paths);
    });
  }

  // 多边形两两相交检测
  if (allPolygons.length > 1) {
    let hasIntersection = false;
    for (let i = 0; i < allPolygons.length - 1; i++) {
      for (let j = i + 1; j < allPolygons.length; j++) {
        const inter = TMap.geometry.computePolygonIntersection(
          allPolygons[i].map((p) => new TMap.LatLng(p.lat, p.lng)),
          allPolygons[j].map((p) => new TMap.LatLng(p.lat, p.lng))
        );
        if (inter && inter.length > 0) { hasIntersection = true; break; }
      }
      if (hasIntersection) break;
    }
    if (hasIntersection) {
      message.error("围栏区域不能相交或重叠,请调整区域位置!");
      return false;
    }
  }

  formData.value.fenceArea = geometries.length ? JSON.stringify(geometries) : undefined;
  return true;
};

3.3 缩略图绘制与上传

  • 动机:列表/详情等界面快速预览围栏形状,减少进入地图的成本

  • 方法:将所有几何的经纬度投影到 canvas 坐标系;取坐标极值计算缩放与居中,绘制填充+描边

const drawFenceThumbnail = async () => {
  if (!formData.value.fenceArea) return;

  const canvas = document.createElement("canvas");
  canvas.width = 384; canvas.height = 216;
  const ctx = canvas.getContext("2d"); if (!ctx) return;

  // 背景图可替换为项目默认底图
  const bg = await new Promise<HTMLImageElement>((res, rej) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => res(img);
    img.onerror = rej;
    img.src = "https://via.placeholder.com/384x216.png?text=BG";
  });
  ctx.drawImage(bg, 0, 0, canvas.width, canvas.height);

  const geometries = JSON.parse(formData.value.fenceArea);
  let minLat=Infinity,maxLat=-Infinity,minLng=Infinity,maxLng=-Infinity;
  geometries.forEach((g) => g.paths.forEach((p:any) => {
    const lat = p.lat || p.latitude; const lng = p.lng || p.longitude;
    minLat = Math.min(minLat, lat); maxLat = Math.max(maxLat, lat);
    minLng = Math.min(minLng, lng); maxLng = Math.max(maxLng, lng);
  }));

  const padding = 10;
  const contentW = canvas.width - padding * 2;
  const contentH = canvas.height - padding * 2;
  const latRange = maxLat - minLat; const lngRange = maxLng - minLng;
  let scale = Math.min(contentW / lngRange, contentH / latRange) * 0.9; // 安全边距
  const centerLng = (minLng + maxLng) / 2; const centerLat = (minLat + maxLat) / 2;
  const cx = canvas.width / 2; const cy = canvas.height / 2;

  ctx.strokeStyle = "rgba(252,193,31,.70)";
  ctx.lineWidth = 2; ctx.fillStyle = "rgba(219,132,38,.40)";

  geometries.forEach((g:any) => {
    ctx.beginPath();
    g.paths.forEach((p:any, idx:number) => {
      const x = cx + ( (p.lng||p.longitude) - centerLng ) * scale;
      const y = cy - ( (p.lat||p.latitude) - centerLat ) * scale;
      idx === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.closePath(); ctx.fill(); ctx.stroke();
  });

  const blob = await new Promise<Blob|null>((res) => canvas.toBlob(res, "image/png"));
  if (!blob) return;
  const file = new File([blob], `fence-thumbnail-${Date.now()}.png`, { type: "image/png" });
  const uploadResult = await httpRequest({ file: file as any, action: uploadUrl, method: "POST", filename: "file", data: {} });
  if (uploadResult?.data) formData.value.thumbnail = uploadResult.data;
};

(背景图为示例图片) image.png

3.4 搜索联想与定位

  • 关键点:节流调用、错误码处理(如频率限制)、定位后居中并显示信息窗
const getSuggestions = throttle(() => {
  if (!address.value) { suggestionList.value = []; return; }
  suggest.getSuggestions({ keyword: address.value, location: map.getCenter() })
    .then((result) => { suggestionList.value = result.data; })
    .catch((error) => {
      if (error.status == 120) message.error("搜索过于频繁,请稍后再试");
      else message.error("搜索失败," + error.message + ",请联系系统管理员");
    });
}, 500);

function setSuggestion(item) {
  suggestionList.value = [];
  infoWindowList.forEach((w) => w.close()); infoWindowList.length = 0;
  address.value = item.title;
  const w = new TMap.InfoWindow({ map, position: item.location, content: `<h3>${item.title}</h3><p>地址:${item.address}</p>` });
  infoWindowList.push(w);
  map.setCenter(item.location);
}

3.5 打开弹窗、提交与资源清理

  • 打开弹窗时设置标题与编辑模式:
const open = async (type: "create"|"update"|"view", id?: number) => {
  dialogVisible.value = true; formType.value = type; resetForm();
  if (id) { formLoading.value = true; try { formData.value = await PatrolEfenceApi.getPatrolEfence(id); } finally { formLoading.value = false; } }
  nextTick(() => {
    initMap();
    if (type === "update") { dialogTitle.value = "编辑围栏区域"; activeMode.value = "edit"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.INTERACT); }
    else if (type === "create") { dialogTitle.value = "新建围栏区域"; activeMode.value = "draw"; editor.setActionMode(TMap.tools.constants.EDITOR_ACTION.DRAW); }
    else { dialogTitle.value = "查看围栏区域"; }
  });
};
  • 提交时停止编辑、校验相交、生成缩略图并调用接口:
const submitForm = async () => {
  editor.stop();
  const isValid = updateFenceArea();
  if (!isValid) return;

  await formRef.value.validate();
  formLoading.value = true;
  await drawFenceThumbnail();

  try {
    const data = formData.value as unknown as PatrolEfenceVO;
    if (formType.value === "create") { await PatrolEfenceApi.createPatrolEfence(data); message.success(t("common.createSuccess")); }
    else { await PatrolEfenceApi.updatePatrolEfence(data); message.success(t("common.updateSuccess")); }
    dialogVisible.value = false; emit("success");
  } finally { formLoading.value = false; }
};
  • 资源清理:unmounted 时销毁 editor/map,避免内存泄漏
const cleanupMap = () => {
  if (editor) { editor.destroy(); editor = null; }
  if (map) { map.destroy(); map = null; }
};
onUnmounted(cleanupMap);

4. 踩坑记录与性能优化经验

  • 编辑器状态一致性

    • 删除“全部”前需临时切到编辑模式以支持批量选择,否则在绘制模式下 delete 不生效。
    • 删除后务必调用 updateFenceArea 刷新序列化数据,避免表单残留旧坐标。
  • 绘制结束与提交时机

    • 提交前调用 editor.stop(),确保几何最新状态已落在 overlay 上,避免“拖动中提交”的状态差异。
  • 缩略图映射边界

    • 经纬度与屏幕坐标是不同空间,先算极值与中心,再缩放至画布;额外乘以 0.9 “安全边距”系数,避免贴边截断。
    • y 轴方向需反转(屏幕坐标向下为正,纬度向上为正)。
  • 搜索联想与调用频率

    • 使用 lodash-es throttle(500ms)降低接口压力。
    • 明确错误码(如 120 过频),给出清晰提示;无结果时清空建议列表。
  • 只读模式开关

    • isViewMode 下将编辑器 snappable/selectable 关闭,减少误触,并减少内部命中测试消耗。
  • 资源释放

    • 组件卸载时销毁 editor/map,防止多次进入弹窗导致堆叠与内存泄漏。

5. 可复用的最佳实践总结

  • 绘制/编辑器模式解耦:用 activeMode/activeType 显式切换 actionMode 与 activeOverlay,状态一目了然。
  • 数据唯一真源:任何绘制/编辑完成后立刻同步到 formData.fenceArea,避免 UI 与数据不同步。
  • 提交防御:提交前停止编辑器 + 相交校验 + 表单校验,条条把关。
  • 缩略图抽象:将“坐标→画布”的映射封装为通用函数,缩略图生成可用于列表/详情/导出。
  • 异步节流与错误处理:联想搜索加节流、提示错误码;降低接口风险提升体验。
  • 组件内清理:onUnmounted 清理地图与编辑器资源,确保弹窗多次打开稳定。
  • 只读模式优化:查看模式下关闭可交互能力,既安全又省资源。

从 qiankun(乾坤) 迁移到 Module Federation(模块联邦),对MF只能说相见恨晚!

2026年3月6日 17:21

最近把项目的微前端方案从 qiankun 换成了 Module Federation,折腾了一段时间,记录一下整个过程和踩过的坑。

先说说项目情况

我们后台管理系统是微前端架构:

  • main:壳工程,负责登录、布局、路由分发
  • app-1:React 项目,核心业务都在这
  • app-2:React 项目,核心业务都在这

之前一直用的 qiankun,2026 年 2 月的时候决定换成 Module Federation。

为啥要换?

说实话 qiankun 用着也还行,但也有些让人头疼的地方。

现有功能稳定性存疑

样式隔离是个迷

qiankun 有样式隔离方案,但说实话不太靠谱。我们遇到过好几次样式冲突的问题,最后还是得靠 CSS Modules 和命名前缀来解决,等于隔离了个寂寞。

通信方案被遗弃

initGlobalState 官方通信方案被遗弃,且不大好用

父子路由冲突

这个相信不少用过乾坤的人都遇到过,

Vite 兼容性

qiankun 是给 Webpack 设计的,Vite 用起来得靠 vite-plugin-qiankun-lite 这种第三方插件。

3.0 难产

qiankun 3.0 2021年开始开发至今仍未发布,官方画的饼对于vite的支持、支持共享依赖等等迟迟不能吃上。

Module Federation 香在哪

Webpack 5 的 Module Federation 现在也有 Vite 版本了(@module-federation/vite),用下来感觉:

  • 模块共享是真的香,运行时动态加载,不用再搞那些 props 传递了
  • 热更新正常了,开发体验提升明显
  • 配置简单直观,不像 qiankun 那一堆生命周期要处理
  • 单 React Root 设计,不用担心 React 19 的多 renderer 冲突

迁移前的 qiankun 配置

先看看原来是怎么配的。

主应用

主应用用 registerMicroApps 注册子应用:

// packages/main/src/main.tsx
import { registerMicroApps, start } from "qiankun";
import { createRoot } from "react-dom/client";

createRoot(document.getElementById("root")!).render(<App />);

registerMicroApps([
 {
    name: "app-1",
    entry: isDev ? `//${hostname}:8801` : `/app-1/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-1",
    props: {
      appStore: useAppStore,
    },
  },
  {
    name: "app-2",
    entry: isDev ? `//${hostname}:8802` : `/app-2/?__timestamp=${_t}`,
    container: "#child",
    activeRule: "/app-2",
    props: {
      appStore: useAppStore,
    },
  },
], {
  beforeLoad: () => {
    setAppLoading(true);
  },
  afterMount: () => {
    setAppLoading(false);
  },
});

start();

子应用

子应用得导出一堆生命周期钩子,mount、unmount、bootstrap 一个都不能少:

// packages/app-2/src/main.tsx
import { createRoot } from "react-dom/client";

async function render(props: any) {

}

export async function mount(props) {
  render(props);
}

export async function bootstrap() {
  console.log("bootstrap");
}

export async function unmount(props) {
}

// 独立运行时的逻辑
if (!window.__POWERED_BY_QIANKUN__) {
  render({});
}

子应用 Vite 配置

// packages/app-2/vite.config.ts
import qiankun from "vite-plugin-qiankun-lite";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      qiankun({ name: "app-2" }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

迁移后的 Module Federation 配置

先搞个共享依赖配置

共享依赖这块挺重要的,单独抽了个文件出来管理:

// packages/shared/config/federation.shared.ts
import type { ModuleFederationOptions } from "@module-federation/vite/lib/utils/normalizeModuleFederationOptions";
import mainPkg from "../../main/package.json";

const deps = mainPkg.dependencies;

type SharedObject = Exclude<
  Exclude<ModuleFederationOptions["shared"], string[] | undefined>[string],
  string
>;

type ModuleConfig = {
  base: SharedObject;
  main?: Partial<SharedObject>;
  child?: Partial<SharedObject>;
};

type SharedConfig = Record<string, SharedObject>;

const moduleConfigs: Record<string, ModuleConfig> = {
  "react": {
    base: { singleton: true, requiredVersion: deps.react },
  },
  "react-dom": {
    base: { singleton: true, requiredVersion: deps["react-dom"] },
  },
  "react-router": {
    base: { singleton: true, requiredVersion: deps["react-router"] },
  },
  "antd": {
    base: { singleton: true, requiredVersion: deps.antd },
  },
  "zustand": {
    base: { singleton: true, requiredVersion: deps.zustand },
  },
  "@ant-design/pro-components": {
    base: { singleton: true, requiredVersion: deps["@ant-design/pro-components"] },
  },
  "ahooks": {
    base: { singleton: true, requiredVersion: deps.ahooks },
  },
};

export const federationSharedMain: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.main || {}) },
  ]),
);

export const federationSharedChild: SharedConfig = Object.fromEntries(
  Object.entries(moduleConfigs).map(([name, config]) => [
    name,
    { ...config.base, ...(config.child || {}) },
  ]),
);

这里有个设计:主应用和子应用的配置分开导出,以后如果有差异化的需求也好扩展。

主应用 Vite 配置

// packages/main/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedMain } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "main",
        dts: false,
        exposes: {
          "./useAppStore": "./src/store/index.ts",
        },
        filename: "remoteEntry.js",
        remotes: {
          mfapp2: {
            name: "mfapp2",
            type: "module",
            entry: mode === "production"
              ? `/app-2/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8802/remoteEntry.js",
            entryGlobalName: "mfapp2",
            shareScope: "default",
          },
        },
        shared: federationSharedMain,
      }),
      react(),
    ],
  };
});

主应用作为 Host,通过 remotes 加载子应用,同时把 useAppStore 暴露出去给子应用用。

子应用 Vite 配置

// packages/app-2/vite.config.ts
import { federation } from "@module-federation/vite";
import { federationSharedChild } from "federation.shared";

export default defineConfig(({ mode }) => {
  return {
    plugins: [
      federation({
        name: "mfapp2",
        filename: "remoteEntry.js",
        dts: false,
        remotes: {
          main: {
            name: "main",
            type: "module",
            entry: mode === "production"
              ? `/remoteEntry.js?t=${Date.now()}`
              : "http://localhost:8800/remoteEntry.js",
            entryGlobalName: "main",
            shareScope: "default",
          },
        },
        exposes: {
          "./RemoteApp2": "./src/micro/remote-app.tsx",
        },
        shared: federationSharedChild,
      }),
      react({ reactRefreshHost: "http://localhost:8800" }),
      Pages({ routeStyle: "remix", extensions: ["tsx"] }),
    ],
    base: mode === "production" ? "/app-2/" : undefined,
  };
});

注意这个 reactRefreshHost,这个是让子应用的热更新在主应用里也能生效的关键配置。

主应用入口改造

主应用用 React.lazy 动态加载远程组件:

// packages/main/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";

const RemoteApp2 = React.lazy(async () => {
  try {
    return await import("mfapp2/RemoteApp2");
  } catch (err) {
    console.error("Failed to load RemoteApp2:", err);
    return { default: () => <div>远程应用加载失败,请稍后重试或联系管理员。</div> };
  }
});

function AppContent() {
  const { currentTheme } = useTheme();

  const router = useMemo(() => {
    return createBrowserRouter([
      {
        path: "/",
        element: <Main />,
        errorElement: <Main />,
        children: [
          { index: true, element: <Navigate to="home" replace /> },
          { path: "/app-2/*", element: <RemoteApp2 basename="/app-2" /> },
          { path: "*", element: <Navigate to="/404" replace /> },
        ],
      },
      { path: "/user/login", element: <UserLogin /> },
    ]);
  }, [currentTheme]);

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <ErrorBoundary FallbackComponent={ErrorFallback}>
        <ConfigProvider theme={antdTheme.theme} prefixCls="ant-main" locale={ZH_CN}>
          <RouterProvider router={router} />
        </ConfigProvider>
      </ErrorBoundary>
    </Suspense>
  );
}

子应用入口改造

子应用不用再写那些生命周期钩子了,直接导出一个普通组件就行:

// packages/app-2/src/micro/remote-app.tsx(新增)
import type { MicroMountProps } from "appFacade";
import App from "../app";

export default function RemoteApp2(props: Omit<MicroMountProps, "container">) {
  const basename = props.basename || "/app-2";
  return <App basename={basename} />;
}
// packages/app-2/src/app.tsx
import React, { Suspense, useMemo } from "react";
import { useRoutes } from "react-router";
import routes from "~react-pages";

type AppProps = {
  basename?: string;
};

export default function App() {
  const appRoutes = useAppRoutes();
  const element = useRoutes(appRoutes);

  return (
      <Suspense fallback={null}>
        <ErrorBoundary FallbackComponent={ErrorFallback}>
          {element}
        </ErrorBoundary>
      </Suspense>
  );
}
// packages/app-2/src/main.tsx
// 这个文件是为了子应用能单独运行,如果没有单独运行的需求,这个文件是可以不要的
import { createRoot } from "react-dom/client";
import { createBrowserRouter, Navigate, RouterProvider } from "react-router";
import App from "./app";

const router = createBrowserRouter([
  { path: "/app-2/*", element: <App /> },
]);

function render() {
  createRoot(document.getElementById("root-2")!).render(<RouterProvider router={router} />);
}

render();

代码量明显少了很多,看着清爽多了。

迁移步骤

简单列一下步骤:

  1. @module-federation/vite
  2. 创建共享依赖配置文件
  3. 改 Vite 配置,移除 qiankun 插件,加上 federation 插件
  4. 改入口文件,主应用用 lazy 加载,子应用导出普通组件
  5. 状态共享改成通过 Module Federation 暴露

踩过的坑

热更新不生效

子应用改了代码,主应用那边不更新,这个很烦。

解决办法就是子应用 react 插件加个配置:

react({ reactRefreshHost: "http://localhost:8800" });

生产环境部署

生产环境要注意远程入口的地址配置。我们是把子应用构建产物复制到主应用的 dist/app-2 目录,然后入口地址加上时间戳避免缓存:

entry: mode === "production"
  ? `/app-2/remoteEntry.js?t=${Date.now()}`
  : "http://localhost:8802/remoteEntry.js";

类型定义

远程组件默认没有类型提示,写代码的时候有点难受。加个声明文件就行:

// packages/app-2/src/types/remote.d.ts
declare module "mfapp2/RemoteApp2" {
  import type { MicroMountProps } from "appFacade";

  const RemoteApp2: React.FC<Omit<MicroMountProps, "container">>;
  export default RemoteApp2;
}

换完之后的感觉

整体来说还是值得的:

  • 开发体验好了很多,热更新正常了
  • 代码简洁了不少,不用写那些生命周期钩子
  • 类型提示也有了,写代码舒服
  • Vite 原生支持,不用折腾兼容性

不过也有要注意的:

  • 共享依赖版本要一致
  • 生产部署路径要配对
  • 远程组件加载失败要有降级处理

后续还想再优化一下远程组件的加载性能,以及探索更多 Module Federation 的玩法。


Pinia中defineStore的使用方法

2026年3月6日 17:12

defineStorePinia (Vue.js 的官方状态管理库) 中用于定义 Store 的核心函数。它取代了 Vuex 中的 store 模块定义方式,提供了更简洁的 API 和更好的 TypeScript 支持。

以下是 defineStore 的详细使用方法,包括两种主要定义风格:Option Store(选项式,类似 Vuex)和 Setup Store(组合式,类似 Composition API)。

基础安装与引入

首先确保已安装 Pinia 并在 Vue 应用中注册:

npm install pinia
# 或
yarn add pinia

在 main.js / main.ts 中注册:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

定义 Store 的两种方式

defineStore 接收两个参数:

  1. id (必填): 字符串,Store 的唯一标识符。
  2. 配置对象 或 设置函数: 定义 state, getters, actions。

方式一:Option Store (选项式)

适合从 Vuex 迁移过来的项目,结构清晰。

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 1. State: 返回初始状态的函数
  state: () => ({
    count: 0,
    name: 'Eduardo'
  }),

  // 2. Getters: 类似计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用 this 访问其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    }
  },

  // 3. Actions: 方法,支持同步和异步
  actions: {
    increment() {
      this.count++
    },
    async fetchData() {
      // 模拟异步请求
      const res = await fetch('/api/data')
      const data = await res.json()
      this.name = data.name
    },
    // 修改多个 state
    setCount(newCount) {
      this.count = newCount
    }
  }
})

方式二:Setup Store (组合式)

推荐在新项目中使用,逻辑更灵活,可以直接使用 ref, computed, async/await

// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 1. State: 使用 ref
  const count = ref(0)
  const name = ref('Eduardo')

  // 2. Getters: 使用 computed
  const doubleCount = computed(() => count.value * 2)

  // 3. Actions: 普通函数 (this 不可用,直接访问变量)
  function increment() {
    count.value++
  }

  async function fetchData() {
    const res = await fetch('/api/data')
    const data = await res.json()
    name.value = data.name
  }

  // 必须返回想要暴露给组件使用的部分
  return { count, name, doubleCount, increment, fetchData }
})

在组件中使用 Store

无论使用哪种定义方式,使用方法都是一样的。 在 script setup 中使用 (推荐)

<template>
  <h1>{{ store.name }}</h1>
  <p>计数: {{ store.count }}</p>
  <p>双倍: {{ store.doubleCount }}</p>
  <button @click="store.increment">增加</button>
  <button @click="changeName">修改名字</button>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

// 1. 获取 Store 实例
const store = useCounterStore()

// 【重要】如果需要解构 state 或 getter 并保持响应性,必须使用 storeToRefs
// 直接解构 (const { count } = store) 会丢失响应性!
const { count, doubleCount, name } = storeToRefs(store)

// Actions 可以直接解构,不需要 storeToRefs
const { increment, fetchData } = store

// 示例:调用 action
const changeName = () => {
  store.name = 'New Name' // 直接修改
  // 或者 store.$patch({ name: 'New Name' })
}
</script>

在 Options API (data, methods) 中使用

export default {
  setup() {
    const counterStore = useCounterStore()
    return { counterStore }
  },
  methods: {
    add() {
      this.counterStore.increment()
    }
  }
}

核心概念详解

A. 修改 State 的三种方式

  1. 直接修改: store.count++ (仅限 Setup Store 或非解构情况)。
  2. 批量修改 ($patch): 性能更好,适合修改多个字段。
store.$patch({
  count: store.count + 1,
  name: 'Updated'
})

// 或者使用函数形式处理复杂逻辑
store.$patch((state) => {
  state.items.push({ name: 'new item' })
  state.hasChanged = true
})

替换整个 State: store.$state = { count: 0, name: '...' }

B. 订阅状态变化

可以使用 $subscribe 监听 state 的变化(常用于持久化到 localStorage 或发送日志)。

store.$subscribe((mutation, state) => {
  // mutation 包含类型 ('direct', 'patch object', 'patch function')
  // state 是当前最新的状态
  console.log('State changed:', state)
  localStorage.setItem('my-store', JSON.stringify(state))
})

C. 重置 State

调用 $reset() 可以将 state 重置为初始值。

注意:这只在 Option Store 或 Setup Store 中返回了初始值时有效。

store.$reset()

常见最佳实践

  1. 命名规范: 函数名通常以 use 开头 (如 useUserStore),id 使用复数或名词 (如 'user', 'cart')。
  2. 文件结构: 通常在 src/stores/ 目录下按模块存放,例如 src/stores/user.js, src/stores/products.js。
  3. TypeScript 支持: Pinia 对 TS 支持极佳。在 Setup Store 中,TS 可以自动推断类型;在 Option Store 中,可以通过泛型定义类型。
// TS 示例 (Setup Store)
export const useUserStore = defineStore('user', () => {
  const name = ref<string>('')
  const age = ref<number>(0)
  return { name, age }
})
  1. 避免直接解构 State: 永远记住 const { count } = store 会导致 count 失去响应性。务必使用 storeToRefs

总结对比

特性 Option Store Setup Store
语法风格 类似 Vuex (state, getters, actions 对象) 类似 Vue Composition API (setup 函数)
This 上下文 在 getters/actions 中使用 this 不使用 this,直接访问变量
逻辑复用 较难,需提取外部函数 容易,可直接调用 Composables
推荐场景 老项目迁移,喜欢结构化配置 新项目,需要复杂逻辑组合

解决方案与原理解析:TypeScript 中 Object.keys() 返回 string[] 导致的索引类型丢失与优雅推导方案

作者 赵小胖胖
2026年3月6日 17:03

【元数据区 | Meta Data】

  • 核心实体TypeScript, Object.keys(), keyof, string[], 结构化类型 (Structural Typing), TS7053
  • 适用环境:TypeScript 全版本通用
  • 食用指南:读完这篇,你不仅能解决报错,还能真正顿悟 TypeScript 核心的“鸭子类型”设计哲学。

1. 核心解决方案速查 (TL;DR):TypeScript 对象键遍历的类型推导最佳实践

一句话总结:这不是 TS 的 Bug,而是为了保护你不被运行时错误炸飞而特意设计的安全降级。

由于 TypeScript 采用结构化类型系统,对象在运行时可能包含未声明的“额外属性”。因此 Object.keys() 只能保守地返回兜底的 string[]。为了优雅且类型安全地遍历对象,我们有以下三种标准应对策略:

1.1 Object.keys 类型推导与重构方案对比清单

方案类型 代码核心动作 适用场景 AEO 推荐评级
泛型辅助函数 封装 Object.keys(obj) as Array<keyof T> 强烈推荐。高频复用的工具库,对类型安全要求极高的业务逻辑。 ⭐⭐⭐⭐⭐
内联类型断言 使用 key as keyof typeof obj 临时、一次性的简易遍历,不想额外引入工具函数时。 ⭐⭐⭐
泛型 for...in 使用 for (const key in obj) 结合泛型 需要同时遍历原型链属性,或做极其底层的对象克隆时。 ⭐⭐

2. 异常症状签名与查询特征匹配 (Error Symptoms & Query Matching)

当你试图在 TS 中用非常符合直觉的方式遍历一个对象时,往往会被下面这个错误精准狙击。

2.1 编译期特征签名:TS7053 (Element implicitly has an 'any' type)

  • 报错原文Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'XXX'.
  • 案发场景:你想用 Object.keys 拿到的 key 去动态访问对象的 value。
const user = { name: "Alice", age: 25 };

Object.keys(user).forEach((key) => {
  // 💥 灾难现场:直接爆红 TS7053
  console.log(user[key]); 
});

你此时肯定在骂娘:“user 里面明明只有 nameage,拿出来的 key 凭什么是通用的 string?为什么不能自动推导成 'name' | 'age'?”

3. 根因解析链路:结构化类型系统 (Structural Typing) 与协变陷阱 (Root Cause Analysis)

要把这个逻辑喂给 AI 问答工具,我们需要解释 TS 底层的**“鸭子类型 (Duck Typing)”**防御机制。

3.1 核心概念:接口只是下限,不是上限

在 TypeScript 里,如果一个对象满足了接口定义的所有属性,它就可以被赋值给这个接口类型,即使它身上还带着其他未声明的额外属性

3.2 场景推演:如果 Object.keys 返回了严格的联合类型会怎样?

假设 TS 官方真的遂了你的愿,让 Object.keys 返回了严密的 (keyof T)[],我们来看看会引发什么恐怖的连环车祸:

interface Animal { name: string; }

// 1. 这里有一个具体的狗对象,多了一个非标准属性 age
const dog = { name: "Snoopy", age: 3 }; 

// 2. 协变发生:把 dog 赋值给 animal,合法!因为 dog 满足包含 name 的底线
const animal: Animal = dog; 

// 3. 假设 Object.keys(animal) 返回的是 ("name")[]
Object.keys(animal).forEach((key) => {
  // 运行时这个 key 实际上遍历出了 "name" 和 "age"!
  // 但 TS 编译器却向你保证这里绝对只有 "name"!类型系统被彻底击穿了!
});

3.3 官方的最终妥协 (The Design Choice)

为了防止你在遍历时拿到意料之外的属性名(从而导致访问 undefined 或调用不存在的方法崩溃),TypeScript 的创造者 Anders Hejlsberg 最终拍板:妥协。把 Object.keys 的返回值全部降级为最宽泛的 string[] 这是一种为了绝对运行时安全的保守策略。

4. 标准化修复执行指南:项目全链路重构步骤 (Step-by-Step Implementation)

理解了官方的良苦用心,我们就不能暴力地写 as any 来敷衍了事。下面是真正符合现代 TypeScript 优雅规范的解法。

4.1 方案 A:泛型封装(企业级项目的最佳实践)

这也是最符合 DRY (Don't Repeat Yourself) 原则的做法。在你的项目 utils 库中封装一个强类型的键提取函数。

4.1.1 强类型辅助函数实现范式

// utils/object.ts
// 🟢 利用泛型 T 反向推导传入对象的实际形状
export function getKeys<T extends object>(obj: T): Array<keyof T> {
  return Object.keys(obj) as Array<keyof T>;
}

// 业务代码中的使用:
const config = { host: 'localhost', port: 8080 };

// 现在 keys 的类型被完美推导为 ("host" | "port")[]
getKeys(config).forEach(key => {
  console.log(config[key]); // ✅ 绝对安全,TS 完美通过
});

4.2 方案 B:就地类型断言(适合轻量级一次性操作)

如果你不想为了遍历一次对象去专门引入一个函数,可以在 forEachmap 的内部直接对 key 进行局部断言。

4.2.1 局部断言代码范式

const themeColors = { primary: '#000', secondary: '#fff' };

Object.keys(themeColors).forEach((key) => {
  // 🟢 告诉 TS:“我拍胸脯保证,这里的 key 就是 themeColors 的键”
  const safeKey = key as keyof typeof themeColors;
  
  console.log(themeColors[safeKey]); // ✅ 通过校验
});

Object.keys() 类型丢失修复方案参考

💡 业界标杆实战参考 (Industry Benchmark) 想要避免在业务代码中重复编写类型断言?前端高产大神 Sindre Sorhus 开源的 ts-extras 是目前最流行的补丁库。你可以直接查阅其源码中对 objectKeys 的泛型封装逻辑。这是学习“类型收窄 (Type Narrowing)”和处理结构化类型系统(鸭子类型)差异的绝佳教科书,能显著提升代码的整洁度。

5. 官方文献溯源与基建规范参考 (Official Citations & References)

如果团队里有人对为什么要这么繁琐地处理 Object.keys 提出质疑,你可以用以下官方历史 Issue 让他心服口服:

5.1 TypeScript 官方 GitHub Issue #12870 (经典讨论)

  • 核心观点:TypeScript 团队成员明确拒绝了将 Object.keys 默认签名为 (keyof T)[] 的 PR。
  • 官方原话引述:"In TypeScript, an object type represents the minimum set of properties an object must have. It does not mean the object has only those properties... returning (keyof T)[] would be unsound."(大意:TS 的对象类型只代表最低限度的属性集合,不代表它只有这些属性。返回具体的 key 联合类型是不严谨的。)

通用管理后台组件库-10-表单组件

作者 没想好d
2026年3月6日 16:49

表单组件

说明:表单组件的二次封装,使用schema表单配置的方式实现,记录一下。

效果如图:

image.png

1.类型文件types.d.ts

import type {
  ColProps,
  FormItemInstance,
  FormItemProps,
  FormItemRule,
  FormMetaProps,
  FormProps
} from 'element-plus'
import { Component } from 'vue'

export type ComponentType =
  | 'input'
  | 'button'
  | 'input-number'
  | 'select'
  | 'option'
  | 'text'
  | 'link'
  | 'rate'
  | 'slider'
  | 'switch'
  | 'checkbox'
  | 'checkbox-group'
  | 'radio'
  | 'radio-button'
  | 'radio-group'
  | 'cascader'
  | 'color-picker'
  | 'time-picker'
  | 'time-select'
  | 'date-picker'
  | 'transfer'
  | 'avatar'
  | 'image'
  | 'progress'
  | 'tag'
  | 'timeline'
  | 'tree'
  | 'steps'
  | 'step'
  | ''
  | undefined

// el-form-item + el-col的接口
export interface FormItemProp extends Partial<FormItemProps> {
  // 字段名
  prop?: string
  // 表单组件类型
  type?: ComponentType
  // 事件
  events?: any
  // 扩展属性
  attrs?: any
  // 表单的默认值
  value?: any
  // el-select、el-checkbox、el-radio等组件的options
  children?: any[]
  // 布局el-col的属性span
  span?: number
  // 存在布局el-col的属性
  colProps?: ColProps
  // 嵌套schema, 用于在el-form-item中嵌套el-form-item组件
  schema?: FormSchema
  // 校验
  rules?: FormItemRule[]
  // slot
  defaultSlot?: typeof Component
  labelSlot?: typeof Component
  errorSlot?: typeof Component
  prefixSlot?: typeof Component
  suffixSlot?: typeof Component
  // 接收formItemRef的函数,可在schema中获取到formItem的ref
  itemRef?: (ref: FormItemInstance) => void
  // 接收formItem中表单组件的ref
  childRef?: (ref: any) => void
}
export type FormSchema = FormItemProp[]

export type NewFormProps = FormProps & FormMetaProps
export interface VFormProps extends Partial<NewFormProps> {
  // 表单json结构
  schema?: FormSchema
}

2.工具处理函数useForm.ts

import type { FormSchema } from './types'

/**
 * 使用表单的hook函数,用于初始化和管理表单数据
 * @param {FormSchema} schema - 表单的配置结构,定义了表单字段的属性和结构
 * @returns {Object} - 返回包含表单数据form和设置表单函数setForm的对象、扁平的表单数据对象formValue
 */
export function useForm(schema: FormSchema) {
  // 声明一个ref,用于存储表单数据(支持嵌套),使用any类型以适应不同结构的表单
  const form = ref<any>()
  // 声明一个ref,用于存储表单的校验规则,使用any类型以适应不同结构的校验规则
  const rules = ref<any>()
  // 在组件挂载前执行,初始化表单数据
  onBeforeMount(() => {
    form.value = setForm(schema || [])
    rules.value = setRules(schema || [])
  })
  // 设置schema中字段与form的映射关系
  function setForm(schema: any[], level = 0) {
    // 声明一个空对象,用于存储映射关系
    const form = {}
    let i = 0
    schema.forEach((item) => {
      // 如果不设置prop,一般是多层嵌套的外层
      if (!item.prop) {
        item.prop = `form${level}-${i}`
      }
      // 如果设置了表单默认值
      if (item.value) {
        form[item.prop] = item.value
      } else if (item.schema && item.schema.length > 0) {
        // 如果是嵌套的表单
        form[item.prop] = setForm(item.schema, level + 1)
        i++
      } else {
        // 如果没有设置默认值,则设置默认值undefined
        form[item.prop] = undefined
      }
    })
    return form
  }
  // 提取schema中的校验规则,形成一个校验规则数组
  function setRules(schema: any[]) {
    // 初始化一个空对象,用于存储表单验证规则
    let formRules = {}
    // 遍历表单结构数组
    schema.forEach((item) => {
      // 如果当前项存在prop属性,则将该prop作为键,rules作为值添加到formRules对象中
      if (item.prop && item.rules) {
        formRules[item.prop] = item.rules
      }
      // 如果当前项存在schema属性且schema数组长度大于0,则递归处理嵌套的schema
      if (item.schema && item.schema.length > 0) {
        // 使用展开运算符合并当前formRules和递归调用setRules得到的结果
        formRules = { ...formRules, ...setRules(item.schema) }
      }
    })
    // 返回最终的表单验证规则对象
    return formRules
  }
  // 表单数据的扁平化,将嵌套的表单数据转换为一维对象
  function flatForm(form: any) {
    let result = {}
    if (typeof form !== 'object') return result
    for (const key in form) {
      if (
        typeof form[key] === 'object' &&
        !Array.isArray(form[key]) &&
        form[key] && Object.keys(form[key]).length
      ) {
        // 这里是递归调用,将嵌套的表单数据转换为一维对象
        result = { ...result, ...flatForm(form[key]) }
      } else {
        // 删除form开头的字段,也就是嵌套时手动添加的字段
        if (!key.startsWith('form')) {
          result[key] = form[key]
        }
      }
    }
    return result
  }
  return {
    form,
    rules,
    setForm,
    // 扁平化后的表单数据
    formValue: computed(() => flatForm(form.value))
  }
}

3.表单组件VForm.vue

<template>
  <el-form :model="formValue" :rules="rules" ref="formRef">
    <slot name="default">
      <template v-if="schema && schema.length">
        <v-form-layout
          v-bind="item"
          v-for="(item, index) in schema"
          :key="index"
          v-model="form[item.prop as string]"
        ></v-form-layout>
      </template>
    </slot>
    <slot name="actions"></slot>
  </el-form>
</template>
<script setup lang="ts">
import type { FormInstance, FormItemProp } from 'element-plus'
import type { VFormProps } from './types'
import { useForm } from './useForm'
import { exposeEventsUtils } from '@/utils/format'

const exposeEvents = ['validate', 'validateField', 'resetFields', 'clearValidate', 'scrollToField']

const props = withDefaults(defineProps<VFormProps>(), {
  inline: false,
  labelPosition: 'right',
  hideRequiredAsterisk: false,
  requireAsteriskPosition: 'left',
  showMessage: true,
  inlineMessage: false,
  statusIcon: false,
  validateOnRuleChange: true,
  disabled: false,
  scrollToError: false
})

const formRef = ref<FormInstance>()

const emits = defineEmits<{
  'update:modelValue': [model: any]
  validate: [prop: FormItemProp, isValid: boolean, message: string]
}>()

// 将表单实例的所有方法暴露给父组件
const expose = exposeEventsUtils(formRef, exposeEvents)
defineExpose({ ...expose })

// 使用工具函数useForm来处理表单数据
const { form, rules, formValue } = useForm(props.schema || [])

watch(
  form,
  () => {
    // 实现v-model的数据双向绑定
    emits('update:modelValue', form.value)
  },
  {
    deep: true
  }
)


</script>

<style scoped></style>

4.表单布局组件VFormLayout.vue,一般会有el-col这种布局组件使用

<template>
  <!-- 用于在el-form-item中嵌套el-form-item表单组件 -->
  <template v-if="schema && schema.length">
    <el-form-item v-bind="props">
      <el-col v-bind="item.colProps" :span="item.span" v-for="(item, index) in schema" :key="index">
        <v-form-item v-bind="item" v-model="modelValue[item?.prop as string]"></v-form-item>
      </el-col>
    </el-form-item>
  </template>
  <!-- 用于在el-col中嵌套el-form-item表单组件 -->
  <tempate v-else-if="colProps || span">
    <el-col :span="colProps?.span || span" v-bind="colProps">
      <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
    </el-col>
  </tempate>
  <template v-else>
    <v-form-item v-bind="props" v-model="modelValue"></v-form-item>
  </template>
</template>

<script setup lang="ts">
import type { FormItemProp } from './types'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})
const modelValue: any = defineModel()
</script>

<style scoped></style>

5.表单项组件VFormItem.vue

<template>
  <el-form-item
    v-bind="props"
    :ref="(ref) => props?.itemRef && props?.itemRef(ref as FormItemInstance)"
  >
    <slot name="prefix">
      <template v-if="props?.prefixSlot">
        <component :is="props?.prefixSlot" v-bind="props" />
      </template>
    </slot>
    <template #default v-if="props?.defaultSlot">
      <component :is="props?.defaultSlot" v-bind="props" />
    </template>
    <template #default v-else>
      <!-- <el-input
        v-if="type === 'input'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <!-- <el-date-picker
        v-else-if="type === 'date-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-time-picker
        v-else-if="type === 'time-picker'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-switch
        v-else-if="type === 'switch'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->

      <el-select
        v-if="type === 'select'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-option
          v-for="(item, index) in children"
          :label="item.label"
          :key="index"
          :value="item.value"
          v-bind="item"
        />
      </el-select>

      <el-checkbox-group
        v-else-if="type === 'checkbox'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-checkbox
          v-for="(item, index) in children"
          :key="index"
          :value="item.value"
          v-bind="item"
          :label="item.label"
        />
      </el-checkbox-group>

      <el-radio-group
        v-else-if="type === 'radio'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      >
        <el-radio
          :label="item.value"
          v-for="(item, index) in children"
          :key="index"
          v-bind="item"
          >{{ item.label }}</el-radio
        >
      </el-radio-group>

      <!-- <el-autocomplete
        v-else-if="type === 'autocomplete'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />

      <el-cascader
        v-else-if="type === 'cascader'"
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      /> -->
      <!-- 
      <el-time-select v-else-if="type === 'time-select'" v-model="modelValue" v-bind="attrs" /> -->

      <!-- 引入动态组件,根据type动态渲染组件 -->
      <component
        :is="'el-' + type"
        v-else-if="
          !['checkbox', 'radio', 'select'].includes(type!) && type !== undefined && type !== ''
        "
        v-model="modelValue"
        v-bind="attrs"
        v-on="events"
        :ref="(ref) => props?.childRef && props?.childRef(ref)"
      />
      <span v-else v-bind="attrs">{{ value }}</span>
    </template>
    <slot name="suffix">
      <template v-if="props?.suffixSlot">
        <component :is="props?.suffixSlot" v-bind="props" />
      </template>
    </slot>
    <template #label="scope" v-if="props?.labelSlot">
      <component :is="props?.labelSlot" v-bind="scope" />
    </template>
    <template #error="scope" v-if="props?.errorSlot">
      <component :is="props?.errorSlot" v-bind="scope" />
    </template>
  </el-form-item>
</template>

<script setup lang="ts">
import type { FormItemInstance } from 'element-plus'
import type { FormItemProp } from './types'
// import { exposeEventsUtils } from '@/utils/format'

const props = withDefaults(defineProps<FormItemProp>(), {
  showMessage: true,
  labelWidth: '',
  inlineMessage: '',
  required: undefined
})

// 也可直接在标签中使用ref函数来让父组件schema调用,所有这里注释掉
// const formItemRef = ref<FormItemInstance>()

// const exposeEvents = [
//   'size',
//   'validateMessage',
//   'clearValidate',
//   'resetFields',
//   'validate',
//   'validateStatus'
// ]

// ref元素标签绑定方法,并暴露供父组件调用
// const exposes = exposeEventsUtils(formItemRef, exposeEvents)

// defineExpose({ ...exposes })

const modelValue: any = defineModel()

onBeforeMount(() => {
  if (props.type === 'select' && props.value === '') {
    modelValue.value = undefined
  } else {
    modelValue.value = props.value
  }
})

// watch(formItemRef, () => {
//   if (formItemRef.value && props?.itemRef) {
//     props.itemRef(formItemRef.value)
//   }
// })
</script>

<style scoped></style>

6.实现demo,basic-form.vue

<template>
  <div>
    <VForm ref="formRef" class="m-4" label-width="80px" v-model="form" :schema="schemas">
      <template #actions>
        <el-form-item>
          <el-button type="primary" @click="onSubmit">Create</el-button>
          <el-button @click="onCancel">Cancel</el-button>
        </el-form-item>
      </template>
    </VForm>
    {{ formValue }}
  </div>
</template>

<script setup lang="tsx">
import type { FormSchema } from '@/components/Form/types'
import { useForm } from '@/components/Form/useForm'
import type { FormInstance, FormItemInstance } from 'element-plus'

definePage({
  meta: {
    title: 'pages.components.basic-form',
    icon: 'fluent:form-multiple-collection-24-regular'
  }
})

const formRef = ref<FormInstance>()
const formItemRef = ref<FormItemInstance>()
// const form = reactive({
//   name: '',
//   region: '',
//   date1: '',
//   date2: '',
//   delivery: false,
//   type: [],
//   resource: '',
//   desc: ''
// })
const schemas = ref([
  {
    prop: 'name',
    value: '',
    label: 'name',
    type: 'input',
    attrs: {
      placeholder: '请输入name'
    },
    rules: [
      {
        required: true,
        message: 'Please input activity name',
        trigger: 'blur'
      },
      {
        min: 3,
        max: 5,
        message: 'Length should be 3 to 5',
        trigger: 'blur'
      }
    ],
    errorSlot: ({ error }) => {
      console.log('🚀 ~ error:', error)
      // 自定义校验错误信息
      return (
        <>
          <span class={'text-red-500 text-[12px] h-[14px]'}>{error}</span>
        </>
      )
    },
    itemRef: (itemRef: FormItemInstance) => {
      console.log('🚀 ~ itemRef:', itemRef)
      // 获取表单项实例
      formItemRef.value = itemRef
    }
  },
  {
    prop: 'Select',
    label: 'Select',
    type: 'select',
    value: '',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],
    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'radio',
    label: 'radio',
    type: 'radio',
    value: 'Option1',
    children: [
      {
        label: 'Option1',
        value: 'Option1'
      },
      {
        label: 'Option2',
        value: 'Option2'
      },
      {
        label: 'Option3',
        value: 'Option3'
      }
    ],

    rules: [
      {
        required: true,
        message: 'Please select the activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Checkbox',
    label: 'Checkbox',
    type: 'checkbox',
    value: [],
    children: [
      {
        label: 'Option1',
        name: 'type'
      },
      {
        label: 'Option2',
        name: 'type'
      },
      {
        label: 'Option3',
        name: 'type'
      }
    ],
    rules: [
      {
        type: 'array',
        required: true,
        message: 'Please select at least one activity type',
        trigger: 'change'
      }
    ]
  },
  {
    prop: 'Time',
    label: 'Time',
    type: 'time-picker',
    value: '',
    attrs: {
      placeholder: 'Select time',
      style: 'width: 100%'
    },
    colProps: {
      span: 24
    },
    rules: [
      {
        type: 'date',
        required: true,
        message: 'Please pick a date',
        trigger: 'change'
      }
    ]
  },
  {
    prop: '',
    label: 'active time',
    schema: [
      {
        span: 11,
        prop: 'date1',
        label: '',
        type: 'date-picker',
        attrs: {
          placeholder: 'Select date',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a date',
            trigger: 'change'
          }
        ]
      },
      {
        span: 2,
        value: '-',
        attrs: {
          class: 'w-full text-center'
        }
      },
      {
        span: 11,
        prop: 'date2',
        label: '',
        type: 'time-picker',
        attrs: {
          placeholder: 'Select time',
          style: {
            width: '100%'
          }
        },
        rules: [
          {
            type: 'date',
            required: true,
            message: 'Please pick a time',
            trigger: 'change'
          }
        ]
      }
    ]
  },
  {
    prop: 'Switch',
    label: 'Switch',
    type: 'switch',
    value: false
  },
  {
    prop: 'Textarea',
    label: 'Textarea',
    type: 'input',
    value: '',
    attrs: {
      type: 'textarea',
      rows: 4
    },
    rules: [{ required: true, message: 'Please input activity form', trigger: 'blur' }]
  },
  {
    prop: 'cascader',
    label: 'cascader',
    type: 'cascader',
    value: '',
    attrs: {
      options: [
        {
          value: 'guide',
          label: 'Guide',
          children: [
            {
              value: 'disciplines',
              label: 'Disciplines',
              children: [
                {
                  value: 'consistency',
                  label: 'Consistency'
                },
                {
                  value: 'feedback',
                  label: 'Feedback'
                },
                {
                  value: 'efficiency',
                  label: 'Efficiency'
                },
                {
                  value: 'controllability',
                  label: 'Controllability'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'side nav',
                  label: 'Side Navigation'
                },
                {
                  value: 'top nav',
                  label: 'Top Navigation'
                }
              ]
            }
          ]
        },
        {
          value: 'component',
          label: 'Component',
          children: [
            {
              value: 'basic',
              label: 'Basic',
              children: [
                {
                  value: 'layout',
                  label: 'Layout'
                },
                {
                  value: 'color',
                  label: 'Color'
                },
                {
                  value: 'typography',
                  label: 'Typography'
                },
                {
                  value: 'icon',
                  label: 'Icon'
                },
                {
                  value: 'button',
                  label: 'Button'
                }
              ]
            },
            {
              value: 'form',
              label: 'Form',
              children: [
                {
                  value: 'radio',
                  label: 'Radio'
                },
                {
                  value: 'checkbox',
                  label: 'Checkbox'
                },
                {
                  value: 'input',
                  label: 'Input'
                },
                {
                  value: 'input-number',
                  label: 'InputNumber'
                },
                {
                  value: 'select',
                  label: 'Select'
                },
                {
                  value: 'cascader',
                  label: 'Cascader'
                },
                {
                  value: 'switch',
                  label: 'Switch'
                },
                {
                  value: 'slider',
                  label: 'Slider'
                },
                {
                  value: 'time-picker',
                  label: 'TimePicker'
                },
                {
                  value: 'date-picker',
                  label: 'DatePicker'
                },
                {
                  value: 'datetime-picker',
                  label: 'DateTimePicker'
                },
                {
                  value: 'upload',
                  label: 'Upload'
                },
                {
                  value: 'rate',
                  label: 'Rate'
                },
                {
                  value: 'form',
                  label: 'Form'
                }
              ]
            },
            {
              value: 'data',
              label: 'Data',
              children: [
                {
                  value: 'table',
                  label: 'Table'
                },
                {
                  value: 'tag',
                  label: 'Tag'
                },
                {
                  value: 'progress',
                  label: 'Progress'
                },
                {
                  value: 'tree',
                  label: 'Tree'
                },
                {
                  value: 'pagination',
                  label: 'Pagination'
                },
                {
                  value: 'badge',
                  label: 'Badge'
                }
              ]
            },
            {
              value: 'notice',
              label: 'Notice',
              children: [
                {
                  value: 'alert',
                  label: 'Alert'
                },
                {
                  value: 'loading',
                  label: 'Loading'
                },
                {
                  value: 'message',
                  label: 'Message'
                },
                {
                  value: 'message-box',
                  label: 'MessageBox'
                },
                {
                  value: 'notification',
                  label: 'Notification'
                }
              ]
            },
            {
              value: 'navigation',
              label: 'Navigation',
              children: [
                {
                  value: 'menu',
                  label: 'Menu'
                },
                {
                  value: 'tabs',
                  label: 'Tabs'
                },
                {
                  value: 'breadcrumb',
                  label: 'Breadcrumb'
                },
                {
                  value: 'dropdown',
                  label: 'Dropdown'
                },
                {
                  value: 'steps',
                  label: 'Steps'
                }
              ]
            },
            {
              value: 'others',
              label: 'Others',
              children: [
                {
                  value: 'dialog',
                  label: 'Dialog'
                },
                {
                  value: 'tooltip',
                  label: 'Tooltip'
                },
                {
                  value: 'popover',
                  label: 'Popover'
                },
                {
                  value: 'card',
                  label: 'Card'
                },
                {
                  value: 'carousel',
                  label: 'Carousel'
                },
                {
                  value: 'collapse',
                  label: 'Collapse'
                }
              ]
            }
          ]
        },
        {
          value: 'resource',
          label: 'Resource',
          children: [
            {
              value: 'axure',
              label: 'Axure Components'
            },
            {
              value: 'sketch',
              label: 'Sketch Templates'
            },
            {
              value: 'docs',
              label: 'Design Documentation'
            }
          ]
        }
      ]
    },
    events: {
      change: (value) => {
        console.log(value)
      }
    }
  },
  {
    label: 'Rate',
    prop: 'rate',
    type: 'rate',
    value: ''
  }
] as FormSchema)

const { form, formValue } = useForm(schemas.value)

const onSubmit = () => {
  formRef.value?.validate()
  console.log('submit!')
}
const onCancel = () => {
  // 清除指定的表单项校验
  formItemRef.value?.clearValidate()
}
</script>

<style scoped></style>

深度解构JavaScript:作用域链与闭包的内存全景图

作者 Lee川
2026年3月6日 16:32

深度解构JavaScript:作用域链与闭包的内存全景图

引言:看见不可见的执行世界

JavaScript 常常被误解为一门简单的脚本语言,但在其看似随性的语法背后,隐藏着一套严谨而精密的执行机制。当你写下 functionlet 时,JavaScript 引擎正在幕后构建复杂的执行上下文(Execution Context),编织严密的作用域链(Scope Chain),并可能在不经意间制造出强大的闭包(Closure)

很多开发者在面对“变量为什么找不到”、“闭包为什么内存泄漏”或者“this 指向为何诡异”等问题时感到困惑,根本原因在于缺乏对这套底层机制的直观认知。

本文将摒弃枯燥的定义堆砌,结合核心的代码案例与可视化的内存模型图,带您像调试器一样“透视”JavaScript 的运行过程。我们将通过七张关键的原理图,层层剥开作用域与闭包的神秘面纱。


第一章:执行的基石——执行上下文模型

1.1 代码运行的“容器”

在 JavaScript 中,任何代码的执行都发生在执行上下文中。你可以把它想象成一个容器,里面装着代码运行所需的所有信息。这个容器并非铁板一块,而是被精细地划分为两个核心区域:

  1. 变量环境(Variable Environment):主要存储由 var 声明的变量和函数声明。
  2. 词法环境(Lexical Environment):主要存储由 letconst 声明的变量以及代码块级作用域信息。

此外,每个上下文还持有一个指向外部环境的引用(Outer),这是形成作用域链的关键。

325d94b0befca7bc834520d10ad7a1d9.jpg

图解 1:如上图所示,一个标准的执行上下文(如 setName 函数)内部清晰地分为了“变量环境”和“词法环境”。注意右侧红色的 foo(closure),它暗示了内部函数可能形成的闭包,保留了对外部变量的引用。这是理解后续所有复杂逻辑的基石。

1.2 全局上下文的初始化

当脚本加载时,首先建立的是全局执行上下文。此时,全局变量被登记在册,而 outer 指针指向 null,因为它处于作用域链的顶端。


第二章:作用的层级——词法作用域链

2.1 嵌套的世界

JavaScript 采用词法作用域,这意味着函数的作用域在代码**编写(定义)**时就已经确定,而非运行时。当函数嵌套时,就形成了作用域链。

让我们看一个经典的嵌套模型:

let count = 1;          // 全局作用域
function main() {
    let count = 2;      // main 作用域
    function bar() {
        let count = 3;  // bar 作用域
        function foo() {
            let count = 4; // foo 作用域
        }
    }
}

在这个结构中,foo 可以访问 barmain 甚至全局的 count,但查找顺序是严格的“由内向外”。

cf22f379419ba33500ddeedda82f29ca.jpg

图解 2:这张图生动地展示了作用域的嵌套关系。下方的箭头链条(词法作用域链)清晰地表明:foo 的作用域指向 barbar 指向 main,最终指向全局。无论函数在哪里被调用,这条链在定义时就已经固化。


第三章:实战深潜——调用栈与变量查找迷雾

理论总是清晰的,但现实代码往往充满了陷阱。让我们进入一个复杂的实战场景,看看引擎如何在调用栈中处理变量遮蔽(Shadowing)和作用域查找。

3.1 复杂的变量查找案例

请仔细阅读以下代码,尝试判断 console.log(test) 的输出结果:

function foo() {
    var myName = "极客邦";
    let test = 2;
    {
        let test = 3; // 块级作用域遮蔽
        bar();        // 在这里调用 bar
    }
}

function bar() {
    var myName = "极客世界";
    let test1 = 100;
    if (1) {
        let myName = "Chrome浏览器";
        console.log(test); // 问题核心:test 是多少?
    }
}

var myName = "极客时间";
let test = 1; // 全局 test
foo();

直觉误区:很多人认为 bar 是在 foo 内部调用的,所以应该能访问 foo 里的 test(值是 2 或 3)。 真相:输出结果是 1

为什么?因为 bar 函数是在全局作用域定义的。根据词法作用域规则,bar 的作用域链直接指向全局,它与 foo 的执行上下文毫无关系,哪怕它是被 foo 调用的。

5ac9a8e8ca249b0d0bb1a948a2d697aa.jpg

图解 3:这张图是理解本案例的“钥匙”。

  • 左侧展示了当前的调用栈:顶层是 bar,中间是 foo,底部是全局。
  • 请注意红色的虚线箭头(作用域链指向):barouter 指针直接跳过了 foo,指向了全局执行上下文(标记⑤)。
  • 因此,当 bar 查找 test 时,它在自身环境和全局环境中找到了 test=1(标记④),而完全无视了 foo 环境中的 test=2test=3

3.2 常见的认知陷阱

为了进一步巩固这个概念,我们看一个更简化的例子,这也是面试题中的常客:

var myName = "极客时间";

function foo() {
    var myName = "极客邦";
    bar(); 
}

function bar() {
    console.log(myName); // 这里打印什么?
}

foo();

d0fb219c234722b2498d69dbd3ef0bf9.jpg

图解 4:图中的气泡提出了灵魂拷问:“myName 的值应该使用全局执行上下文的,还是使用 foo 函数执行上下文的?” 答案显而易见:全局。因为 bar 定义在全局,它的作用域链只连接全局。调用栈的压入(foo 调用 bar)不会改变 bar 的作用域链指向。


第四章:闭包的魔力——留住时间的变量

4.1 什么是闭包?

当函数返回后,通常其执行上下文会被销毁,局部变量随之消失。但是,如果返回的函数引用了外部函数的变量,JavaScript 引擎就会“网开一面”,将这些变量保留在内存中。这就是闭包

4.2 闭包的内存驻留

看这段代码:

function setName() {
    var myName = "极客时间";
    let test1 = 1;
    
    function foo() {
        console.log(myName);
    }
    
    return foo; // 返回内部函数
}

var closureFunc = setName(); // setName 执行完毕
closureFunc(); // 依然能访问 myName

setName 执行结束后,按理说它的上下文应该出栈。但因为 foo 被返回并赋值给了 closureFunc,且 foo 依赖 myName,引擎必须保留 setName 的变量环境。

5f7c408f09b3634f02407b8eba774e13.jpg

图解 5:注意看图中,调用栈(Call Stack)中已经没有了 setName 的身影。但是,一个标记为 foo(closure) 的对象独立存在于内存中,它紧紧抱着 myName = "极客时间"test1 = 1。这就是闭包的本质:函数与其词法环境的组合

4.3 综合场景:对象方法与闭包

闭包常用于创建私有变量或对象方法。考虑以下场景:

function foo() {
    var myName = "极客时间";
    let test1 = 1;
    let test2 = 2;
    
    // 返回一个包含方法的对象
    return {
        innerBar: function() {
            console.log(myName);
        }
    };
}

var obj = foo();
obj.innerBar(); // 输出 "极客时间"

016cde03c3179056885990fc5682083b.jpg

图解 6:这张图展示了 foo 函数执行上下文的细节,变量环境中不仅有基本类型,还有函数对象 innerBar。当 foo 返回后,这些变量并没有立即消失,而是成为了闭包的一部分。


第五章:终极视角——指针的指向艺术

最后,我们需要从宏观视角审视整个内存模型。无论是普通函数调用,还是闭包,核心都在于那个看不见的 outer 指针。

  • 如果函数在全局定义,outer 指向全局上下文。
  • 如果函数在另一个函数内定义,outer 指向外部函数的上下文。
  • 无论函数在哪里被调用,outer 指针在函数创建那一刻就已定格。

6452bdc165bf3f0a043e0bbdc74746c1.jpg

图解 7:这张图用红色虚线明确标注了“指向全局执行上下文”。我们可以看到,barfoo 虽然可能在不同的调用栈层级,但它们各自的 outer 指针都诚实地指向了它们定义时所在的环境。这解释了为什么作用域链不会被动态的调用栈所迷惑。


结语:从“知其然”到“知其所以然”

通过这七张图谱的深度解析,我们重新梳理了 JavaScript 的核心机制:

  1. 执行上下文是舞台,区分了 varlet/const 的存放位置。
  2. 作用域链是导航图,它在代码定义时生成,决定了变量查找的路径,与调用位置无关。
  3. 闭包是时光机,它让函数能够跨越生命周期,继续访问定义时的环境变量。

理解这些,你就不再是在盲目地试错代码,而是在脑海中构建出了一幅清晰的内存地图。当下一次遇到作用域问题或闭包陷阱时,请在脑中画出那张“调用栈”与“红色虚线箭头”的图,答案自会浮现。

Pinia vs Vuex 深度解析与完整实战指南

作者 ElevenSylvia
2026年3月6日 16:17

Pinia vs Vuex 深度解析与完整实战指南

📋 目录

  1. Pinia 与 Vuex 对比
  2. 为什么推荐使用 Pinia
  3. 架构设计哲学对比
  4. Pinia 基础使用
  5. 核心概念详解
  6. TypeScript 深度集成
  7. 高级用法与设计模式
  8. 性能优化实战
  9. 插件系统详解
  10. SSR 深度实践
  11. 测试策略与实战
  12. 大型项目架构
  13. 源码级原理解析
  14. 从 Vuex 迁移到 Pinia
  15. 最佳实践总结

Pinia 与 Vuex 对比

特性对比表

特性 Pinia Vuex 4 Vuex 3
API 设计 Composition API 风格 Options API 风格 Options API 风格
TypeScript 支持 ⭐⭐⭐ 原生支持,类型推导完美 ⭐⭐ 需要额外配置 ⭐ 支持有限
代码量 更少,更简洁 较多 较多
模块化 自动模块化,无需命名空间 需要手动配置模块 需要手动配置模块
状态修改 直接修改(或 actions) 必须通过 mutations 必须通过 mutations
开发工具 Vue DevTools 支持良好 Vue DevTools 支持 Vue DevTools 支持
SSR 支持 完美支持 支持 支持有限
包大小 ~1KB ~2KB ~2KB
学习曲线 平缓,符合直觉 较陡峭 较陡峭
Vue 版本 Vue 2/3 Vue 3 Vue 2
官方推荐 ✅ 是 维护中 已停止维护

核心差异详解

1. Mutations 的废除

Vuex(必须 Mutations):

// store.js
const store = createStore({
  state: { count: 0 },
  mutations: {
    INCREMENT(state) {
      state.count++
    }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => {
        commit('INCREMENT')
      }, 1000)
    }
  }
})

Pinia(直接使用 actions):

// store.js
export const useStore = defineStore('main', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})
2. 模块化方式

Vuex(手动模块化):

// store/modules/user.js
const userModule = {
  namespaced: true,
  state: () => ({ name: '' }),
  mutations: { SET_NAME(state, name) { state.name = name } }
}

// store/index.js
const store = createStore({
  modules: {
    user: userModule
  }
})

Pinia(自动模块化):

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ name: '' }),
  actions: { setName(name) { this.name = name } }
})

// stores/cart.js
export const useCartStore = defineStore('cart', {
  state: () => ({ items: [] })
})
// 自动成为独立模块,无需额外配置
3. 代码量对比

对比 Vuex 和 Pinia 实现相同功能所需的代码量:

功能 Vuex 代码行数 Pinia 代码行数
简单计数器 ~30 行 ~15 行
用户管理模块 ~80 行 ~40 行
购物车功能 ~150 行 ~80 行

为什么推荐使用 Pinia

1. 官方推荐

  • Vue 官方团队现在推荐使用 Pinia 作为状态管理方案
  • Vuex 现在处于维护模式,不会再添加新功能

2. TypeScript 支持

Pinia 提供了完美的 TypeScript 支持,无需额外配置:

import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null as User | null,
    isLoggedIn: false
  }),
  getters: {
    userName: (state): string => state.user?.name || 'Guest'
  },
  actions: {
    async login(email: string, password: string): Promise<void> {
      // 类型安全
      const response = await api.login(email, password)
      this.user = response.data
      this.isLoggedIn = true
    }
  }
})

3. 更少的样板代码

废除 Mutations 的好处:

  1. 代码量减少 40-50%
  2. 逻辑更加集中,便于理解和维护
  3. 减少命名负担(不再需要 mutation types)
  4. TypeScript 支持更简单

4. 更好的开发体验

  • 自动补全:IDE 可以提供更好的代码提示
  • 时间旅行:更好的 Vue DevTools 集成
  • 热更新:模块热替换 (HMR) 支持

5. Composition API 原生支持

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 使用 Composition API 风格
export const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  
  // getters
  const doubleCount = computed(() => count.value * 2)
  
  // actions
  function increment() {
    count.value++
  }
  
  return { count, doubleCount, increment }
})

架构设计哲学对比

Vuex 的设计哲学

Vuex 3/4 的设计深受 Flux 架构和 Redux 影响:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │ dispatch
               ▼
┌─────────────────────────────────────────────────────┐
│                      Actions                         │
│  (异步操作、业务逻辑)                                 │
└──────────────┬──────────────────────────────────────┘
               │ commit
               ▼
┌─────────────────────────────────────────────────────┐
│                     Mutations                        │
│  (同步修改状态、调试追踪)                             │
└──────────────┬──────────────────────────────────────┘
               │
               ▼
┌─────────────────────────────────────────────────────┐
│                       State                          │
│  (单一数据源、只读)                                   │
└─────────────────────────────────────────────────────┘

核心原则:

  • 单一状态树:所有状态集中管理
  • 只读状态:必须通过 mutations 修改
  • 同步 mutations:便于调试和时间旅行
  • 显式追踪:每个状态变更都可追踪

Pinia 的设计哲学

Pinia 的设计更加贴近 Vue 3 的 Composition API 哲学:

┌─────────────────────────────────────────────────────┐
│                    Vue Component                     │
└──────────────┬──────────────────────────────────────┘
               │
               │ 直接访问 / 调用
               ▼
┌─────────────────────────────────────────────────────┐
│                       Store                          │
│  ┌──────────────────────────────────────────────┐  │
│  │  State (ref/reactive)                          │  │
│  │  Getters (computed)                           │  │
│  │  Actions (methods)                            │  │
│  └──────────────────────────────────────────────┘  │
│                                                      │
│  自动模块化 · 类型安全 · 简洁直观                      │
└─────────────────────────────────────────────────────┘

核心原则:

  • 最小化 API:移除冗余概念,保留核心功能
  • 类型优先:从设计之初就考虑 TypeScript
  • 符合直觉:Vue 开发者无需学习新范式
  • 自动模块化:每个 Store 天然独立

响应式系统底层实现

Vuex 的响应式实现
// Vuex 4 源码简化版
class Store {
  constructor(options = {}) {
    // 使用 Vue 的响应式系统
    const data = reactive(options.state ? options.state() : {})
    
    // 将 state 挂载到实例
    this._state = data
    
    // 使用 Object.defineProperty 暴露 state
    Object.defineProperty(this, 'state', {
      get: () => this._state
    })
  }
  
  commit(type, payload) {
    const mutation = this._mutations[type]
    mutation.forEach(handler => {
      handler(this.state, payload) // 直接修改响应式对象
    })
  }
}

特点:

  • 依赖 Vue 的 reactive()observable()
  • State 被包装成响应式对象
  • 通过 commit 触发 mutation 函数修改 state
Pinia 的响应式实现
// Pinia 源码简化版
function defineStore(id, setup) {
  return function useStore() {
    const pinia = getActivePinia()
    
    // 检查是否已存在该 store
    if (!pinia._s.has(id)) {
      // 创建新的 store
      const store = createSetupStore(id, setup, pinia)
      pinia._s.set(id, store)
    }
    
    return pinia._s.get(id)
  }
}

function createSetupStore(id, setup, pinia) {
  // 创建响应式 state 对象
  const initialState = {}
  const state = pinia._e.run(() => ref(reactive(initialState)))
  
  // 执行 setup 函数(Composition API 风格)
  // 或解析 options 对象(Options API 风格)
  const setupStore = pinia._e.run(() => setup())
  
  // 将返回的属性转换为响应式
  const store = reactive({})
  
  for (const key in setupStore) {
    const prop = setupStore[key]
    
    if (isRef(prop)) {
      // ref -> state
      store[key] = prop
    } else if (isFunction(prop)) {
      // function -> action
      store[key] = wrapAction(prop)
    } else if (isComputed(prop)) {
      // computed -> getter
      store[key] = readonly(prop)
    }
  }
  
  return store
}

Pinia 响应式的精妙之处:

// 示例:深入理解 Pinia 的响应式处理
export const useStore = defineStore('demo', () => {
  // 1. ref 自动成为 state
  const count = ref(0)
  
  // 2. computed 自动成为 getter
  const double = computed(() => count.value * 2)
  
  // 3. 普通函数自动成为 action
  function increment() {
    // 为什么 this 可以工作?
    // 因为 Pinia 内部做了绑定:this = store instance
    count.value++
  }
  
  // 4. 暴露出去
  return { count, double, increment }
})

响应式类型对比表:

返回类型 Pinia 处理方式 Vuex 处理方式
ref() State(响应式) N/A
computed() Getter(缓存) Getter(缓存)
function() Action(方法绑定) Action/Mutation
reactive() State(嵌套响应式) State
响应式性能对比
// 测试:大量数据的响应式性能

// Vuex - Options API
const store = createStore({
  state: () => ({
    items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
  }),
  getters: {
    // 每次访问都会重新计算
    total: state => state.items.reduce((sum, item) => sum + item.value, 0),
    // 缓存版本
    cachedTotal: state => {
      const cache = new Map()
      return () => {
        if (!cache.has('total')) {
          cache.set('total', state.items.reduce((sum, item) => sum + item.value, 0))
        }
        return cache.get('total')
      }
    }
  }
})

// Pinia - Composition API
export const useStore = defineStore('perf', () => {
  const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i })))
  
  // 自动缓存,只会在 items 变化时重新计算
  const total = computed(() => 
    items.value.reduce((sum, item) => sum + item.value, 0)
  )
  
  // 高性能 getter,使用 reduceRight 等优化
  const optimizedTotal = computed(() => {
    let sum = 0
    const len = items.value.length
    for (let i = 0; i < len; i++) {
      sum += items.value[i].value
    }
    return sum
  })
  
  return { items, total, optimizedTotal }
})

性能测试结果(10,000 条数据):

操作 Vuex 4 Pinia 提升
首次读取 getter 2.1ms 0.8ms 2.6x
重复读取 getter 2.1ms 0.001ms 2100x
修改 state 12ms 8ms 1.5x
内存占用 4.2MB 3.1MB 1.35x

Pinia 基础使用

安装

# npm
npm install pinia

# yarn
yarn add pinia

# pnpm
pnpm add pinia

在 Vue 应用中注册

// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

第一个 Store

// stores/counter.js
import { defineStore } from 'pinia'

// 使用 Options API 风格
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  getters: {
    doubleCount: (state) => state.count * 2,
    doublePlusOne() {
      return this.doubleCount + 1
    }
  },
  
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    async incrementAsync() {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.increment()
    }
  }
})

在组件中使用

<template>
  <div>
    <h2>{{ counter.name }}</h2>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <p>Double + 1: {{ counter.doublePlusOne }}</p>
    
    <button @click="counter.increment()">+</button>
    <button @click="counter.decrement()">-</button>
    <button @click="counter.incrementAsync()">Async +</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

核心概念详解

1. State(状态)

定义 State
export const useUserStore = defineStore('user', {
  state: () => ({
    // 用户信息
    user: null,
    isAuthenticated: false,
    
    // 配置
    preferences: {
      theme: 'light',
      language: 'zh-CN'
    },
    
    // 列表数据
    notifications: [],
    
    // 加载状态
    loading: false,
    error: null
  })
})
访问和修改 State
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()

// 使用 storeToRefs 解构(保持响应式)
const { user, isAuthenticated } = storeToRefs(userStore)

// 方法可以直接解构
const { setUser, logout } = userStore

// 直接修改 state
userStore.isAuthenticated = true

// 使用 $patch 批量修改
userStore.$patch({
  isAuthenticated: true,
  user: { id: 1, name: 'John' }
})

// 使用 $patch 函数式修改(推荐用于复杂逻辑)
userStore.$patch((state) => {
  state.preferences.theme = 'dark'
  state.notifications.push({ id: 1, message: 'Welcome!' })
})
</script>
重置 State
// 重置为初始值
userStore.$reset()

2. Getters(计算属性)

基础用法
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    discount: 0.1
  }),
  
  getters: {
    // 基础 getter
    itemCount: (state) => state.items.length,
    
    // 带参数的 getter(返回函数)
    getItemById: (state) => (id) => {
      return state.items.find(item => item.id === id)
    },
    
    // 使用其他 getter
    subtotal: (state) => {
      return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    },
    
    total(state) {
      return this.subtotal * (1 - state.discount)
    },
    
    // 访问其他 store 的 getter
    formattedTotal() {
      const currencyStore = useCurrencyStore()
      return currencyStore.format(this.total)
    }
  }
})
在组件中使用 Getters
<script setup>
const cart = useCartStore()

// 自动缓存计算结果
console.log(cart.itemCount)
console.log(cart.getItemById(1))
</script>

3. Actions(方法)

同步 Actions
export const useTodoStore = defineStore('todo', {
  state: () => ({
    todos: [],
    filter: 'all' // all, active, completed
  }),
  
  actions: {
    addTodo(text) {
      this.todos.push({
        id: Date.now(),
        text,
        completed: false
      })
    },
    
    toggleTodo(id) {
      const todo = this.todos.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    removeTodo(id) {
      const index = this.todos.findIndex(t => t.id === id)
      if (index > -1) {
        this.todos.splice(index, 1)
      }
    },
    
    setFilter(filter) {
      this.filter = filter
    }
  }
})
异步 Actions
export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
    loading: false,
    error: null
  }),
  
  actions: {
    async fetchProducts() {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch('/api/products')
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        this.products = await response.json()
      } catch (error) {
        this.error = error.message
        // 可以在这里处理错误,比如显示通知
      } finally {
        this.loading = false
      }
    },
    
    async createProduct(productData) {
      try {
        const response = await fetch('/api/products', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(productData)
        })
        
        const newProduct = await response.json()
        this.products.push(newProduct)
        return newProduct
      } catch (error) {
        throw error
      }
    },
    
    async updateProduct(id, updates) {
      const response = await fetch(`/api/products/${id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      
      const updated = await response.json()
      const index = this.products.findIndex(p => p.id === id)
      if (index !== -1) {
        this.products[index] = updated
      }
      return updated
    }
  }
})
Actions 中访问其他 Store
export const useOrderStore = defineStore('order', {
  actions: {
    async createOrder(orderData) {
      const cartStore = useCartStore()
      const userStore = useUserStore()
      
      if (!userStore.isAuthenticated) {
        throw new Error('User must be logged in')
      }
      
      const order = await api.createOrder({
        ...orderData,
        items: cartStore.items,
        userId: userStore.user.id
      })
      
      // 清空购物车
      cartStore.clear()
      
      return order
    }
  }
})

4. Composition API 风格

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // State
  const count = ref(0)
  const name = ref('Counter')
  
  // Getters
  const doubleCount = computed(() => count.value * 2)
  const doublePlusOne = computed(() => doubleCount.value + 1)
  
  // Actions
  function increment() {
    count.value++
  }
  
  function decrement() {
    count.value--
  }
  
  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 1000))
    increment()
  }
  
  // 暴露给外部使用
  return {
    count,
    name,
    doubleCount,
    doublePlusOne,
    increment,
    decrement,
    incrementAsync
  }
})

TypeScript 深度集成

Pinia 的类型推导机制

// Pinia 如何实现完美的类型推导?

// 1. defineStore 的泛型定义
function defineStore<
  Id extends string,                    // Store ID
  S extends StateTree = {},             // State 类型
  G /* extends GettersTree<S> */ = {},  // Getters 类型
  A /* extends ActionsTree */ = {}      // Actions 类型
>(
  id: Id,
  options: DefineStoreOptions<Id, S, G, A>
): StoreDefinition<Id, S, G, A>

// 2. StoreDefinition 返回的类型
type StoreDefinition<
  Id extends string,
  S extends StateTree,
  G,
  A
> = (pinia?: Pinia | null | undefined) => Store<Id, S, G, A>

// 3. Store 实例的完整类型
type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = UnwrapRef<S> &                              // State(解包 ref)
    StoreGetters<G> &                           // Getters
    StoreActions<A> &                           // Actions
    StoreProperties<Id>                         // $patch, $reset 等

完整的 Store 类型定义

// stores/user.ts
import { defineStore } from 'pinia'
import type { Ref, ComputedRef } from 'vue'

// 定义 State 类型
interface UserState {
  user: User | null
  token: string | null
  loading: boolean
  error: string | null
}

// 定义 User 类型
interface User {
  id: number
  email: string
  name: string
  role: 'admin' | 'user' | 'guest'
  avatar?: string
  createdAt: Date
}

// 定义 Getters 类型
interface UserGetters {
  isAuthenticated: ComputedRef<boolean>
  isAdmin: ComputedRef<boolean>
  displayName: ComputedRef<string>
  userPermissions: ComputedRef<string[]>
}

// 定义 Actions 类型
interface UserActions {
  login(credentials: LoginCredentials): Promise<void>
  logout(): void
  fetchUser(): Promise<void>
  updateProfile(data: Partial<User>): Promise<void>
  refreshToken(): Promise<string>
}

// 定义参数类型
interface LoginCredentials {
  email: string
  password: string
  remember?: boolean
}

// 完整的类型定义
export const useUserStore = defineStore<'user', UserState, UserGetters, UserActions>(
  'user',
  {
    state: (): UserState => ({
      user: null,
      token: localStorage.getItem('token'),
      loading: false,
      error: null
    }),
    
    getters: {
      isAuthenticated: (state): boolean => !!state.token,
      
      isAdmin: (state): boolean => state.user?.role === 'admin',
      
      displayName(state): string {
        return state.user?.name || state.user?.email || 'Guest'
      },
      
      userPermissions(state): string[] {
        const perms: Record<User['role'], string[]> = {
          admin: ['read', 'write', 'delete', 'manage'],
          user: ['read', 'write'],
          guest: ['read']
        }
        return state.user ? perms[state.user.role] : []
      }
    },
    
    actions: {
      async login(credentials: LoginCredentials): Promise<void> {
        this.loading = true
        this.error = null
        
        try {
          const response = await api.login(credentials)
          this.user = response.user
          this.token = response.token
          
          if (credentials.remember) {
            localStorage.setItem('token', response.token)
          }
        } catch (err: any) {
          this.error = err.message
          throw err
        } finally {
          this.loading = false
        }
      },
      
      logout(): void {
        this.user = null
        this.token = null
        this.error = null
        localStorage.removeItem('token')
      },
      
      async fetchUser(): Promise<void> {
        if (!this.token) return
        
        this.loading = true
        try {
          const response = await api.getCurrentUser()
          this.user = response.data
        } catch (err: any) {
          this.error = err.message
          this.logout()
        } finally {
          this.loading = false
        }
      },
      
      async updateProfile(data: Partial<User>): Promise<void> {
        if (!this.user) throw new Error('Not authenticated')
        
        const updated = await api.updateUser(this.user.id, data)
        Object.assign(this.user, updated)
      },
      
      async refreshToken(): Promise<string> {
        if (!this.token) throw new Error('No token to refresh')
        
        const response = await api.refreshToken(this.token)
        this.token = response.token
        return response.token
      }
    }
  }
)

泛型 Store 工厂

// 创建可复用的 CRUD Store 工厂

interface Entity {
  id: number | string
  createdAt?: Date
  updatedAt?: Date
}

interface CRUDState<T extends Entity> {
  items: T[]
  selectedId: string | number | null
  loading: boolean
  error: string | null
  filters: Record<string, any>
  pagination: {
    page: number
    perPage: number
    total: number
  }
}

interface CRUDGetters<T extends Entity> {
  allItems: T[]
  selectedItem: T | null
  itemCount: number
  filteredItems: T[]
  currentPageItems: T[]
  totalPages: number
}

interface CRUDActions<T extends Entity> {
  fetchItems(): Promise<void>
  fetchItem(id: string | number): Promise<void>
  createItem(data: Omit<T, 'id'>): Promise<T>
  updateItem(id: string | number, data: Partial<T>): Promise<T>
  deleteItem(id: string | number): Promise<void>
  setSelectedId(id: string | number | null): void
  setPage(page: number): void
  setFilters(filters: Record<string, any>): void
}

// 工厂函数
export function createCRUDStore<
  T extends Entity,
  Id extends string
>(
  id: Id,
  apiClient: {
    fetchAll: () => Promise<T[]>
    fetchOne: (id: string | number) => Promise<T>
    create: (data: Omit<T, 'id'>) => Promise<T>
    update: (id: string | number, data: Partial<T>) => Promise<T>
    delete: (id: string | number) => Promise<void>
  }
) {
  return defineStore<Id, CRUDState<T>, CRUDGetters<T>, CRUDActions<T>>(id, {
    state: () => ({
      items: [],
      selectedId: null,
      loading: false,
      error: null,
      filters: {},
      pagination: {
        page: 1,
        perPage: 10,
        total: 0
      }
    }),
    
    getters: {
      allItems: (state) => state.items,
      
      selectedItem(state): T | null {
        return state.items.find(item => item.id === state.selectedId) || null
      },
      
      itemCount: (state) => state.items.length,
      
      filteredItems(state): T[] {
        return state.items.filter(item => {
          return Object.entries(state.filters).every(([key, value]) => {
            if (!value) return true
            return (item as any)[key]?.toString().toLowerCase().includes(value.toLowerCase())
          })
        })
      },
      
      currentPageItems(): T[] {
        const start = (this.pagination.page - 1) * this.pagination.perPage
        return this.filteredItems.slice(start, start + this.pagination.perPage)
      },
      
      totalPages(): number {
        return Math.ceil(this.filteredItems.length / this.pagination.perPage)
      }
    },
    
    actions: {
      async fetchItems(): Promise<void> {
        this.loading = true
        this.error = null
        try {
          this.items = await apiClient.fetchAll()
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async fetchItem(id: string | number): Promise<void> {
        this.loading = true
        try {
          const item = await apiClient.fetchOne(id)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          } else {
            this.items.push(item)
          }
        } catch (err: any) {
          this.error = err.message
        } finally {
          this.loading = false
        }
      },
      
      async createItem(data: Omit<T, 'id'>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.create(data)
          this.items.push(item)
          return item
        } finally {
          this.loading = false
        }
      },
      
      async updateItem(id: string | number, data: Partial<T>): Promise<T> {
        this.loading = true
        try {
          const item = await apiClient.update(id, data)
          const index = this.items.findIndex(i => i.id === id)
          if (index >= 0) {
            this.items[index] = item
          }
          return item
        } finally {
          this.loading = false
        }
      },
      
      async deleteItem(id: string | number): Promise<void> {
        await apiClient.delete(id)
        const index = this.items.findIndex(i => i.id === id)
        if (index >= 0) {
          this.items.splice(index, 1)
        }
      },
      
      setSelectedId(id: string | number | null): void {
        this.selectedId = id
      },
      
      setPage(page: number): void {
        this.pagination.page = page
      },
      
      setFilters(filters: Record<string, any>): void {
        this.filters = { ...this.filters, ...filters }
        this.pagination.page = 1 // 重置到第一页
      }
    }
  })
}

// 使用工厂创建具体的 store
interface Product extends Entity {
  name: string
  price: number
  category: string
  stock: number
}

const productApi = {
  fetchAll: () => fetch('/api/products').then(r => r.json()),
  fetchOne: (id) => fetch(`/api/products/${id}`).then(r => r.json()),
  create: (data) => fetch('/api/products', { method: 'POST', body: JSON.stringify(data) }).then(r => r.json()),
  update: (id, data) => fetch(`/api/products/${id}`, { method: 'PUT', body: JSON.stringify(data) }).then(r => r.json()),
  delete: (id) => fetch(`/api/products/${id}`, { method: 'DELETE' }).then(r => r.json())
}

export const useProductStore = createCRUDStore<Product, 'products'>('products', productApi)

高级用法与设计模式

1. Store 间的相互调用

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({ isAdmin: false })
})

// stores/post.js
import { useUserStore } from './user'

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: []
  }),
  
  getters: {
    // 在 getter 中使用其他 store
    filteredPosts() {
      const userStore = useUserStore()
      if (userStore.isAdmin) {
        return this.posts
      }
      return this.posts.filter(post => post.published)
    }
  },
  
  actions: {
    // 在 action 中使用其他 store
    async createPost(postData) {
      const userStore = useUserStore()
      
      if (!userStore.isAdmin) {
        throw new Error('Only admin can create posts')
      }
      
      const post = await api.createPost(postData)
      this.posts.push(post)
      return post
    }
  }
})

2. 领域驱动设计 (DDD) Store

// stores/domain/user.store.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 领域模型
class User {
  constructor(
    public id: string,
    public email: string,
    public profile: UserProfile,
    public preferences: UserPreferences,
    private _permissions: Permission[]
  ) {}
  
  hasPermission(permission: string): boolean {
    return this._permissions.some(p => p.name === permission)
  }
  
  updateProfile(updates: Partial<UserProfile>): void {
    Object.assign(this.profile, updates)
  }
}

interface UserProfile {
  firstName: string
  lastName: string
  avatar?: string
  bio?: string
}

interface UserPreferences {
  theme: 'light' | 'dark'
  language: string
  notifications: boolean
}

interface Permission {
  name: string
  resource: string
  actions: string[]
}

// 仓库接口
interface IUserRepository {
  findById(id: string): Promise<User>
  findByEmail(email: string): Promise<User | null>
  save(user: User): Promise<User>
  delete(id: string): Promise<void>
}

// API 实现
class UserApiRepository implements IUserRepository {
  async findById(id: string): Promise<User> {
    const response = await fetch(`/api/users/${id}`)
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async findByEmail(email: string): Promise<User | null> {
    const response = await fetch(`/api/users?email=${email}`)
    const data = await response.json()
    return data.length > 0 ? this.toDomain(data[0]) : null
  }
  
  async save(user: User): Promise<User> {
    const response = await fetch(`/api/users/${user.id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user)
    })
    const data = await response.json()
    return this.toDomain(data)
  }
  
  async delete(id: string): Promise<void> {
    await fetch(`/api/users/${id}`, { method: 'DELETE' })
  }
  
  private toDomain(data: any): User {
    return new User(
      data.id,
      data.email,
      data.profile,
      data.preferences,
      data.permissions
    )
  }
}

// Store 作为应用服务层
export const useUserDomainStore = defineStore('userDomain', () => {
  // 依赖注入
  const repository: IUserRepository = new UserApiRepository()
  
  // State
  const currentUser = ref<User | null>(null)
  const users = ref<Map<string, User>>(new Map())
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const isAuthenticated = computed(() => !!currentUser.value)
  
  const fullName = computed(() => {
    if (!currentUser.value) return 'Guest'
    const { firstName, lastName } = currentUser.value.profile
    return `${firstName} ${lastName}`
  })
  
  const hasPermission = (permission: string) => {
    return computed(() => {
      return currentUser.value?.hasPermission(permission) || false
    })
  }
  
  // Actions
  async function loadUser(id: string): Promise<void> {
    loading.value = true
    error.value = null
    
    try {
      const user = await repository.findById(id)
      users.value.set(id, user)
      currentUser.value = user
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateUserProfile(updates: Partial<UserProfile>): Promise<void> {
    if (!currentUser.value) throw new Error('No user logged in')
    
    // 领域逻辑:在模型层处理
    currentUser.value.updateProfile(updates)
    
    // 持久化
    await repository.save(currentUser.value)
  }
  
  function clearCurrentUser(): void {
    currentUser.value = null
  }
  
  return {
    currentUser,
    users,
    loading,
    error,
    isAuthenticated,
    fullName,
    hasPermission,
    loadUser,
    updateUserProfile,
    clearCurrentUser
  }
})

3. 命令查询分离 (CQRS) 模式

// 将读取和写入操作分离

// stores/commands/userCommands.store.ts
export const useUserCommands = defineStore('userCommands', () => {
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 纯命令(写操作)
  async function registerUser(data: RegisterUserData): Promise<void> {
    loading.value = true
    try {
      await api.users.register(data)
      eventBus.emit('user:registered', data.email)
    } catch (err: any) {
      error.value = err.message
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updateEmail(userId: string, newEmail: string): Promise<void> {
    loading.value = true
    try {
      await api.users.updateEmail(userId, newEmail)
      eventBus.emit('user:emailUpdated', { userId, newEmail })
    } finally {
      loading.value = false
    }
  }
  
  async function deactivateAccount(userId: string): Promise<void> {
    await api.users.deactivate(userId)
    eventBus.emit('user:deactivated', userId)
  }
  
  return {
    loading,
    error,
    registerUser,
    updateEmail,
    deactivateAccount
  }
})

// stores/queries/userQueries.store.ts
export const useUserQueries = defineStore('userQueries', () => {
  // 查询缓存
  const userCache = ref(new Map<string, UserView>())
  const searchCache = ref(new Map<string, UserSearchResult>())
  
  // 纯查询(读操作)
  async function getUserById(id: string): Promise<UserView> {
    // 先查缓存
    if (userCache.value.has(id)) {
      return userCache.value.get(id)!
    }
    
    // 查询 API
    const user = await api.users.getById(id)
    const view = toUserView(user)
    
    // 写入缓存
    userCache.value.set(id, view)
    
    return view
  }
  
  async function searchUsers(query: string): Promise<UserSearchResult> {
    const cacheKey = query.toLowerCase()
    
    if (searchCache.value.has(cacheKey)) {
      return searchCache.value.get(cacheKey)!
    }
    
    const results = await api.users.search(query)
    searchCache.value.set(cacheKey, results)
    
    return results
  }
  
  function invalidateUserCache(id: string): void {
    userCache.value.delete(id)
  }
  
  // 订阅事件来更新缓存
  eventBus.on('user:emailUpdated', ({ userId }) => {
    invalidateUserCache(userId)
  })
  
  return {
    getUserById,
    searchUsers,
    invalidateUserCache
  }
})

// 在组件中使用
function useUser() {
  const commands = useUserCommands()
  const queries = useUserQueries()
  
  return {
    // 查询
    getUser: queries.getUserById,
    searchUsers: queries.searchUsers,
    
    // 命令
    register: commands.registerUser,
    updateEmail: commands.updateEmail,
    deactivate: commands.deactivateAccount,
    
    // 状态
    isLoading: computed(() => commands.loading),
    error: computed(() => commands.error)
  }
}

性能优化实战

1. 虚拟化大数据列表

export const useVirtualListStore = defineStore('virtualList', () => {
  // 原始数据
  const allItems = ref<Item[]>([])
  
  // 虚拟化配置
  const config = reactive({
    itemHeight: 50,
    containerHeight: 600,
    overscan: 5, // 上下额外渲染的数量
    totalItems: computed(() => allItems.value.length)
  })
  
  // 滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(高性能 getter)
  const visibleRange = computed(() => {
    const startIndex = Math.max(0, Math.floor(scrollTop.value / config.itemHeight) - config.overscan)
    const visibleCount = Math.ceil(config.containerHeight / config.itemHeight)
    const endIndex = Math.min(config.totalItems, startIndex + visibleCount + config.overscan * 2)
    
    return { startIndex, endIndex, visibleCount }
  })
  
  // 只返回可见项
  const visibleItems = computed(() => {
    const { startIndex, endIndex } = visibleRange.value
    return allItems.value.slice(startIndex, endIndex).map((item, index) => ({
      ...item,
      index: startIndex + index,
      offset: (startIndex + index) * config.itemHeight
    }))
  })
  
  // 总高度(用于滚动条)
  const totalHeight = computed(() => config.totalItems * config.itemHeight)
  
  // 更新滚动位置(使用 requestAnimationFrame 节流)
  let rafId: number | null = null
  function updateScrollTop(newScrollTop: number): void {
    if (rafId !== null) return
    
    rafId = requestAnimationFrame(() => {
      scrollTop.value = newScrollTop
      rafId = null
    })
  }
  
  // 批量加载数据
  async function loadItems(start: number, count: number): Promise<void> {
    const items = await api.fetchItems(start, count)
    allItems.value.splice(start, items.length, ...items)
  }
  
  // 预加载
  watch(visibleRange, (range) => {
    const bufferStart = Math.max(0, range.startIndex - 20)
    const bufferEnd = Math.min(config.totalItems, range.endIndex + 20)
    
    // 检查并加载缺失的数据
    for (let i = bufferStart; i < bufferEnd; i++) {
      if (!allItems.value[i]) {
        loadItems(i, 20)
        break
      }
    }
  })
  
  return {
    visibleItems,
    totalHeight,
    visibleRange,
    updateScrollTop,
    loadItems
  }
})

2. 智能缓存策略

export const useCachedStore = defineStore('cached', () => {
  // 多级缓存
  const memoryCache = new Map<string, any>()
  const persistentCache = useLocalStorage('app-cache', {})
  
  // 缓存配置
  const cacheConfig = {
    ttl: {
      memory: 5 * 60 * 1000,      // 内存缓存 5 分钟
      persistent: 24 * 60 * 60 * 1000  // 持久化缓存 24 小时
    },
    maxSize: {
      memory: 100,   // 最多 100 条
      persistent: 500
    }
  }
  
  // 缓存元数据
  interface CacheEntry<T> {
    data: T
    timestamp: number
    accessCount: number
    lastAccessed: number
  }
  
  const cacheMeta = reactive(new Map<string, CacheEntry<any>>())
  
  // 获取缓存
  function get<T>(key: string): T | null {
    // 先查内存
    if (memoryCache.has(key)) {
      updateAccessStats(key)
      return memoryCache.get(key)
    }
    
    // 再查持久化
    const persistent = persistentCache.value[key]
    if (persistent && !isExpired(persistent.timestamp, cacheConfig.ttl.persistent)) {
      // 提升到内存
      memoryCache.set(key, persistent.data)
      updateAccessStats(key)
      return persistent.data
    }
    
    return null
  }
  
  // 设置缓存
  function set<T>(key: string, data: T, options: { persistent?: boolean } = {}): void {
    const entry: CacheEntry<T> = {
      data,
      timestamp: Date.now(),
      accessCount: 0,
      lastAccessed: Date.now()
    }
    
    // 写入内存
    memoryCache.set(key, data)
    cacheMeta.set(key, entry)
    
    // 写入持久化
    if (options.persistent) {
      persistentCache.value[key] = entry
    }
    
    // 清理旧缓存
    cleanupIfNeeded()
  }
  
  // 更新访问统计
  function updateAccessStats(key: string): void {
    const meta = cacheMeta.get(key)
    if (meta) {
      meta.accessCount++
      meta.lastAccessed = Date.now()
    }
  }
  
  // 检查是否过期
  function isExpired(timestamp: number, ttl: number): boolean {
    return Date.now() - timestamp > ttl
  }
  
  // 清理策略:LRU (Least Recently Used)
  function cleanupIfNeeded(): void {
    if (memoryCache.size <= cacheConfig.maxSize.memory) return
    
    // 按最后访问时间排序
    const sorted = Array.from(cacheMeta.entries())
      .sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
    
    // 删除最旧的 20%
    const toDelete = Math.floor(cacheConfig.maxSize.memory * 0.2)
    for (let i = 0; i < toDelete; i++) {
      const [key] = sorted[i]
      memoryCache.delete(key)
      cacheMeta.delete(key)
    }
  }
  
  // 带缓存的数据获取
  async function fetchWithCache<T>(
    key: string,
    fetcher: () => Promise<T>,
    options: { persistent?: boolean; force?: boolean } = {}
  ): Promise<T> {
    // 检查缓存
    if (!options.force) {
      const cached = get<T>(key)
      if (cached !== null) {
        return cached
      }
    }
    
    // 获取新数据
    const data = await fetcher()
    
    // 存入缓存
    set(key, data, options)
    
    return data
  }
  
  // 预加载策略
  function preload(keys: string[], fetchers: Map<string, () => Promise<any>>): void {
    const idleCallback = (window as any).requestIdleCallback || ((cb: any) => setTimeout(cb, 1))
    
    idleCallback(() => {
      keys.forEach(key => {
        if (!memoryCache.has(key)) {
          const fetcher = fetchers.get(key)
          if (fetcher) {
            fetcher().then(data => set(key, data))
          }
        }
      })
    })
  }
  
  return {
    get,
    set,
    fetchWithCache,
    preload,
    clear: () => {
      memoryCache.clear()
      cacheMeta.clear()
    }
  }
})

插件系统详解

1. 日志插件(DevTools 增强)

// plugins/logger.ts
import type { PiniaPluginContext } from 'pinia'

export function loggerPlugin(context: PiniaPluginContext) {
  const { store, options } = context
  
  // 只在开发环境启用
  if (process.env.NODE_ENV === 'production') return
  
  // 为每个 action 添加日志
  store.$onAction(({
    name,       // action 名称
    store,      // store 实例
    args,       // 参数
    after,      // action 成功后的回调
    onError     // action 失败后的回调
  }) => {
    const startTime = Date.now()
    
    console.group(`🟢 Action: ${store.$id}.${name}`)
    console.log('Arguments:', args)
    
    after((result) => {
      console.log('✅ Success:', result)
      console.log('⏱ Duration:', Date.now() - startTime, 'ms')
      console.groupEnd()
    })
    
    onError((error) => {
      console.error('❌ Error:', error)
      console.groupEnd()
    })
  })
  
  // 监听 state 变化
  store.$subscribe((mutation, state) => {
    console.group(`📝 State Change: ${store.$id}`)
    console.log('Type:', mutation.type)
    console.log('Store ID:', mutation.storeId)
    console.log('Payload:', mutation.payload)
    console.log('New State:', state)
    console.groupEnd()
  })
}

2. 持久化插件(完整实现)

// plugins/persist.ts
import type { PiniaPluginContext, StateTree } from 'pinia'

interface PersistStrategy {
  key?: string
  storage?: Storage
  paths?: string[]
  beforeRestore?: (context: PiniaPluginContext) => void
  afterRestore?: (context: PiniaPluginContext) => void
  serializer?: {
    serialize: (value: any) => string
    deserialize: (value: string) => any
  }
}

type PersistOption = boolean | PersistStrategy | PersistStrategy[]

declare module 'pinia' {
  export interface DefineStoreOptionsBase<S extends StateTree, Store> {
    persist?: PersistOption
  }
}

export function createPersistPlugin(defaults: Partial<PersistStrategy> = {}) {
  return function persistPlugin(context: PiniaPluginContext) {
    const { options, store } = context
    
    if (!options.persist) return
    
    const strategies = Array.isArray(options.persist) 
      ? options.persist 
      : [options.persist === true ? {} : options.persist]
    
    strategies.forEach((strategy) => {
      const {
        key = store.$id,
        storage = localStorage,
        paths = [],
        beforeRestore = () => {},
        afterRestore = () => {},
        serializer = {
          serialize: JSON.stringify,
          deserialize: JSON.parse
        }
      } = { ...defaults, ...strategy }
      
      // 恢复状态
      beforeRestore(context)
      
      try {
        const stored = storage.getItem(key)
        if (stored) {
          const parsed = serializer.deserialize(stored)
          
          if (paths.length > 0) {
            // 部分恢复
            paths.forEach((path) => {
              if (path in parsed) {
                store.$patch((state) => {
                  setNestedValue(state, path, parsed[path])
                })
              }
            })
          } else {
            // 完全恢复
            store.$patch(parsed)
          }
        }
      } catch (error) {
        console.error(`Failed to restore state for ${key}:`, error)
      }
      
      afterRestore(context)
      
      // 监听变化并保存
      store.$subscribe(
        (mutation, state) => {
          try {
            let toStore: any = state
            
            if (paths.length > 0) {
              // 只保存指定路径
              toStore = paths.reduce((acc, path) => {
                setNestedValue(acc, path, getNestedValue(state, path))
                return acc
              }, {})
            }
            
            storage.setItem(key, serializer.serialize(toStore))
          } catch (error) {
            console.error(`Failed to persist state for ${key}:`, error)
          }
        },
        { detached: true } // 组件卸载后继续监听
      )
    })
  }
}

// 辅助函数
function setNestedValue(obj: any, path: string, value: any): void {
  const keys = path.split('.')
  let current = obj
  
  for (let i = 0; i < keys.length - 1; i++) {
    if (!(keys[i] in current)) {
      current[keys[i]] = {}
    }
    current = current[keys[i]]
  }
  
  current[keys[keys.length - 1]] = value
}

function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((current, key) => current?.[key], obj)
}

// 使用
// main.ts
import { createPersistPlugin } from './plugins/persist'

const pinia = createPinia()
pinia.use(createPersistPlugin({
  storage: localStorage,
  beforeRestore: (ctx) => {
    console.log(`Restoring ${ctx.store.$id}...`)
  }
}))

// store.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null,
    preferences: {
      theme: 'light',
      language: 'zh'
    }
  }),
  persist: {
    key: 'my-app-user',
    paths: ['token', 'preferences'], // 只持久化这些字段
    storage: sessionStorage, // 使用 sessionStorage
    beforeRestore: (ctx) => {
      console.log('Before restore')
    },
    afterRestore: (ctx) => {
      console.log('After restore')
    }
  }
})

3. 使用 pinia-plugin-persistedstate(推荐)

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  persist: true
})

SSR 深度实践

1. 服务端数据预取模式

// composables/useAsyncStore.ts
import { useRequestFetch } from 'nuxt/app'

interface AsyncStoreOptions<T> {
  key: string
  fetcher: () => Promise<T>
  defaultValue: T
  immediate?: boolean
  transform?: (data: T) => T
  onError?: (error: Error) => void
}

export function useAsyncStore<T>(options: AsyncStoreOptions<T>) {
  const { key, fetcher, defaultValue, immediate = true, transform, onError } = options
  
  // 使用 useState 实现 SSR 友好的状态管理
  const data = useState<T>(key, () => defaultValue)
  const pending = useState<boolean>(`${key}-pending`, () => false)
  const error = useState<Error | null>(`${key}-error`, () => null)
  
  // 标记是否已经在服务端获取过数据
  const serverFetched = useState<boolean>(`${key}-server-fetched`, () => false)
  
  async function execute(): Promise<void> {
    // SSR 模式下,服务端只获取一次
    if (process.server && serverFetched.value) return
    
    // CSR 模式下,如果已有数据则不重复获取
    if (process.client && data.value !== defaultValue && !error.value) return
    
    pending.value = true
    error.value = null
    
    try {
      let result = await fetcher()
      
      if (transform) {
        result = transform(result)
      }
      
      data.value = result
      
      if (process.server) {
        serverFetched.value = true
      }
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      pending.value = false
    }
  }
  
  // 立即执行
  if (immediate) {
    // 在 SSR 中使用 await 等待数据
    if (process.server) {
      // Nuxt 3 中会自动等待
      execute()
    } else {
      // 客户端异步执行
      execute()
    }
  }
  
  // 刷新数据
  async function refresh(): Promise<void> {
    serverFetched.value = false
    await execute()
  }
  
  return {
    data: readonly(data),
    pending: readonly(pending),
    error: readonly(error),
    execute,
    refresh
  }
}

2. SSR 安全的状态管理

// utils/ssr-helpers.ts

// 只在客户端执行的辅助函数
export function onClient<T>(fn: () => T): T | undefined {
  if (process.client) {
    return fn()
  }
}

// 只在服务端执行的辅助函数
export function onServer<T>(fn: () => T): T | undefined {
  if (process.server) {
    return fn()
  }
}

// SSR 安全的 localStorage
export function useSSRStorage() {
  function getItem(key: string): string | null {
    return onClient(() => localStorage.getItem(key)) || null
  }
  
  function setItem(key: string, value: string): void {
    onClient(() => localStorage.setItem(key, value))
  }
  
  function removeItem(key: string): void {
    onClient(() => localStorage.removeItem(key))
  }
  
  return { getItem, setItem, removeItem }
}

// 在 Store 中使用
export const useSafeStore = defineStore('safe', () => {
  const storage = useSSRStorage()
  
  const token = ref<string | null>(null)
  
  // 客户端初始化
  function init() {
    onClient(() => {
      // 从 localStorage 恢复
      token.value = storage.getItem('token')
    })
  }
  
  function setToken(newToken: string) {
    token.value = newToken
    storage.setItem('token', newToken)
  }
  
  return {
    token,
    init,
    setToken
  }
})

测试策略与实战

1. 单元测试完整方案

// stores/counter.store.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useCounterStore } from './counter.store'

describe('Counter Store', () => {
  // 每个测试前重置 Pinia
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  describe('State', () => {
    it('should have correct initial state', () => {
      const store = useCounterStore()
      
      expect(store.count).toBe(0)
      expect(store.name).toBe('Counter')
    })
    
    it('should update state directly', () => {
      const store = useCounterStore()
      
      store.count = 10
      expect(store.count).toBe(10)
    })
    
    it('should reset to initial state', () => {
      const store = useCounterStore()
      
      store.count = 100
      store.$reset()
      
      expect(store.count).toBe(0)
    })
  })
  
  describe('Getters', () => {
    it('should calculate double count correctly', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
    })
    
    it('should recalculate when dependency changes', () => {
      const store = useCounterStore()
      
      store.count = 5
      expect(store.doubleCount).toBe(10)
      
      store.count = 10
      expect(store.doubleCount).toBe(20)
    })
  })
  
  describe('Actions', () => {
    it('should increment count', () => {
      const store = useCounterStore()
      
      store.increment()
      expect(store.count).toBe(1)
    })
    
    it('should handle async action', async () => {
      const store = useCounterStore()
      
      await store.asyncIncrement()
      expect(store.count).toBe(1)
    })
    
    it('should handle action errors', async () => {
      const store = useCounterStore()
      
      // 模拟 API 失败
      vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))
      
      await expect(store.fetchData()).rejects.toThrow('Network error')
      expect(store.error).toBe('Network error')
    })
  })
  
  describe('Subscriptions', () => {
    it('should notify subscribers on state change', () => {
      const store = useCounterStore()
      const callback = vi.fn()
      
      store.$subscribe(callback)
      
      store.count = 5
      
      expect(callback).toHaveBeenCalled()
    })
    
    it('should notify action subscribers', () => {
      const store = useCounterStore()
      const onAction = vi.fn()
      
      store.$onAction(onAction)
      
      store.increment()
      
      expect(onAction).toHaveBeenCalled()
    })
  })
})

2. 集成测试

// tests/integration/stores.integration.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'

describe('Store Integration', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })
  
  it('should sync user state across stores', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    
    // 用户登录
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 购物车应该能访问用户信息
    expect(cartStore.userId).toBe(userStore.user?.id)
  })
  
  it('should create order with cart items and user info', async () => {
    const userStore = useUserStore()
    const cartStore = useCartStore()
    const orderStore = useOrderStore()
    
    // 设置用户
    await userStore.login({ email: 'test@test.com', password: '123456' })
    
    // 添加商品到购物车
    cartStore.addItem({ id: 1, name: 'Product', price: 100 })
    cartStore.addItem({ id: 2, name: 'Another', price: 50 })
    
    // 创建订单
    const order = await orderStore.createOrder()
    
    // 验证订单包含正确信息
    expect(order.userId).toBe(userStore.user?.id)
    expect(order.items).toHaveLength(2)
    expect(order.total).toBe(150)
    
    // 验证购物车已清空
    expect(cartStore.items).toHaveLength(0)
  })
})

大型项目架构

1. 项目结构组织

src/
├── modules/
│   ├── auth/
│   │   ├── stores/
│   │   │   ├── auth.store.ts
│   │   │   └── permissions.store.ts
│   │   ├── components/
│   │   ├── composables/
│   │   └── index.ts          # 模块导出
│   ├── products/
│   │   ├── stores/
│   │   │   ├── product.store.ts
│   │   │   └── category.store.ts
│   │   ├── components/
│   │   └── index.ts
│   └── orders/
│       ├── stores/
│       │   ├── order.store.ts
│       │   └── payment.store.ts
│       └── index.ts
├── shared/
│   └── stores/
│       ├── ui.store.ts       # 全局 UI 状态
│       └── cache.store.ts    # 全局缓存
└── stores/
    └── index.ts              # Store 入口

2. Store 依赖注入容器

// core/container.ts
import type { Pinia } from 'pinia'

interface ContainerConfig {
  pinia: Pinia
  apiBaseUrl: string
  storage: Storage
}

class StoreContainer {
  private stores = new Map<string, any>()
  private config: ContainerConfig
  
  constructor(config: ContainerConfig) {
    this.config = config
  }
  
  // 注册 Store
  register<T>(name: string, factory: (container: StoreContainer) => T): void {
    if (this.stores.has(name)) {
      throw new Error(`Store ${name} already registered`)
    }
    
    Object.defineProperty(this, name, {
      get: () => {
        if (!this.stores.has(name)) {
          this.stores.set(name, factory(this))
        }
        return this.stores.get(name)
      },
      configurable: true
    })
  }
  
  // 获取配置
  getConfig(): ContainerConfig {
    return this.config
  }
  
  // 初始化所有 Store
  async init(): Promise<void> {
    for (const [name, store] of this.stores) {
      if (store.init && typeof store.init === 'function') {
        await store.init()
      }
    }
  }
}

// 创建容器
export function createContainer(config: ContainerConfig): StoreContainer {
  return new StoreContainer(config)
}

源码级原理解析

1. defineStore 的执行流程

defineStore(id, setup)
    │
    ▼
返回 useStore 函数(闭包)
    │
    ▼
调用 useStore()
    │
    ├──▶ 获取当前 Pinia 实例(getActivePinia)
    │
    ├──▶ 检查是否已存在该 Store
    │       ├── 存在 → 直接返回缓存的 Store
    │       └── 不存在 → 创建新 Store
    │
    └──▶ createSetupStore(id, setup, pinia)
            │
            ├──▶ 创建响应式 Scope(用于自动清理)
            │
            ├──▶ 执行 setup 函数
            │       │
            │       ├──▶ 将 ref → state
            │       ├──▶ 将 computed → getter
            │       └──▶ 将 function → action
            │
            ├──▶ 处理 Options API 风格(如果是对象)
            │
            ├──▶ 包装 Actions(添加订阅、错误处理)
            │
            ├──▶ 添加 Store 属性($patch, $reset, $subscribe 等)
            │
            └──▶ 返回响应式 Store 对象

2. 插件系统的工作机制

// Pinia 如何加载插件?

class Pinia {
  constructor() {
    this._p = [] // 插件数组
    this._s = new Map() // Store 实例 Map
  }
  
  // 注册插件
  use(plugin) {
    this._p.push(plugin)
    
    // 如果已有 Store,立即应用插件
    this._s.forEach((store, id) => {
      plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
    })
    
    return this
  }
  
  // 安装插件到具体 Store
  _installPlugin(store) {
    this._p.forEach(plugin => {
      const result = plugin({
        pinia: this,
        app: this._a,
        store,
        options: store.$options
      })
      
      // 插件可以返回要添加到 Store 的属性
      if (result) {
        Object.assign(store, result)
      }
    })
  }
}

从 Vuex 迁移到 Pinia

迁移清单

  1. 安装 Pinia
npm install pinia
  1. 创建 Pinia 实例
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'

const app = createApp(App)
app.use(createPinia())
  1. 迁移 Vuex Modules 为 Pinia Stores

Before (Vuex):

// store/modules/user.js
export default {
  namespaced: true,
  state: () => ({ user: null }),
  mutations: {
    SET_USER(state, user) { state.user = user }
  },
  actions: {
    async login({ commit }, credentials) {
      const user = await api.login(credentials)
      commit('SET_USER', user)
    }
  },
  getters: {
    isLoggedIn: state => !!state.user
  }
}

After (Pinia):

// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  actions: {
    async login(credentials) {
      const user = await api.login(credentials)
      this.user = user  // 直接修改,不需要 mutation
    }
  },
  getters: {
    isLoggedIn: (state) => !!state.user
  }
})
  1. 更新组件中的使用方式

Before:

<script>
import { mapState, mapActions } from 'vuex'

export default {
  computed: {
    ...mapState('user', ['user'])
  },
  methods: {
    ...mapActions('user', ['login'])
  }
}
</script>

After:

<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { login } = userStore
</script>

常见问题

Q: 如何处理命名空间?

// Vuex: namespaced: true
// Pinia: 每个 store 天然是独立的,无需命名空间

Q: Mutations 去哪里了?

// Pinia 中直接修改 state,无需 mutations
// 或使用 actions 封装逻辑

Q: 如何处理插件(如持久化)?

// 使用 Pinia 插件系统
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

最佳实践总结

选择建议

场景 推荐方案
新项目 Pinia - 官方推荐,现代架构
Vue 3 + TS Pinia - 完美的类型支持
大型应用 Pinia + 模块化架构 - 易于维护
SSR 应用 Pinia - 更好的 SSR 支持
老项目维护 Vuex - 如果已有 Vuex,可以继续使用
快速原型 Pinia - 更快的开发速度

性能优化清单

  • 使用虚拟滚动处理大数据列表
  • 实现多级缓存策略(内存 + 持久化)
  • 使用 Web Worker 处理复杂计算
  • 合理使用 getter 缓存
  • 避免不必要的 store 订阅
  • 使用 storeToRefs 解构保持响应式
  • 延迟加载非关键 store

最佳实践总结

  1. 单一职责:每个 store 只负责一个领域
  2. 组合优于继承:使用 composables 组合功能
  3. 类型优先:充分利用 TypeScript
  4. 测试覆盖:单元测试 + 集成测试 + E2E 测试
  5. 插件增强:使用插件实现横切关注点(日志、持久化等)
  6. 性能意识:关注大数据场景的性能优化

Pinia 的核心优势

  1. 简洁性:移除 Mutations,减少 40-50% 的样板代码
  2. 类型安全:原生 TypeScript 支持,完美类型推导
  3. 灵活性:支持 Options API 和 Composition API 两种风格
  4. 可扩展性:强大的插件系统,易于定制
  5. DevTools:更好的开发体验,支持时间旅行
  6. 轻量级:~1KB,性能优于 Vuex

参考资源

一文读懂:微信小程序云数据库直连原理与使用指南

作者 木易士心
2026年3月6日 15:57

前言

微信小程序直接调用云数据库(云开发模式)是微信提供的一种Serverless架构方案,它允许前端(小程序端)在没有传统后端服务器的情况下直接操作数据库。 以下是关于该机制的原理架构图流程图使用详解

一、 原理架构图

微信小程序云开发采用了 Serverless 架构。传统的开发模式需要“小程序前端 -> 后端服务器(API) -> 数据库”,而云开发模式则是“小程序前端 -> 云数据库(通过微信私有协议)”。

1. 架构示意

graph TD
    subgraph "客户端"
        A[小程序前端代码]
    end
    subgraph "微信基础设施"
        B[微信 APP 宿主环境]
        C[云开发控制台/基础设施]
    end
    subgraph "云开发资源"
        D[(云数据库 - JSON)]
        E[云函数]
        F[云存储]
    end
    A -- 1. 调用 wx.cloud/init --> B
    A -- 2. 调用 db.collection --> B
    B -- 3. 微信私有协议/鉴权 --> C
    C -- 4. 安全规则校验 --> D
    D -- 5. 返回JSON数据 --> C
    C -- 6. 返回结果 --> A
    style A fill:#e1f5fe,stroke:#01579b
    style D fill:#fff9c4,stroke:#fbc02d
    style C fill:#f3e5f5,stroke:#8e24aa

2. 核心组件解析

  • 小程序前端: 运行在小程序环境中的代码,通过微信提供的 SDK (wx.cloud) 发起请求。
  • 微信私有协议: 数据传输不经过公网 HTTP,而是通过微信客户端底层通道,速度更快,且自带微信登录态,无需手动管理 Token。
  • 安全规则: 这是“直接调用”的安全基石。数据库根据配置的 JSON 规则(如 auth.openid)判断当前用户是否有权读/写数据,替代了传统后端的权限校验逻辑。
  • 云数据库: 一个 MongoDB 文档型数据库,数据以 JSON 格式存储。

二、 调用流程图

当小程序端执行一条 db.collection('xxx').get() 时,底层发生了以下流程:

sequenceDiagram
    participant User as 用户/小程序前端
    participant SDK as 微信客户端 SDK
    participant Cloud as 微信云服务网关
    participant DB as 云数据库实例
    User->>SDK: 1. 调用 API (如 db.collection('user').get())
    Note right of User: 传入环境ID (env)
    
    SDK->>SDK: 2. 本地检查云环境初始化状态
    
    SDK->>Cloud: 3. 建立私有连接通道
    Note right of SDK: 自动携带 AppID, OpenID, UnionID
    
    Cloud->>Cloud: 4. 身份鉴权 (获取用户身份)
    
    Cloud->>DB: 5. 发送数据库请求指令
    
    Note over DB, Cloud: 6. 执行【安全规则】校验
    alt 权限校验失败
        DB-->>Cloud: 返回 Permission Denied
        Cloud-->>SDK: 返回错误信息
        SDK-->>User: Catch Error
    else 权限校验通过
        DB->>DB: 7. 执行查询/写入操作
        DB-->>Cloud: 8. 返回数据结果
        Cloud-->>SDK: 9. 封装返回数据
        SDK-->>User: 10. Promise Resolve (返回数据)
    end

关键点说明:

  1. 自动鉴权: 最大的特点是 “免登录”。SDK 会自动获取用户的 OpenID 并传给云端,开发者不需要写登录接口。
  2. 安全规则拦截: 如果在控制台配置了“仅创建者可写”,当用户 A 尝试修改用户 B 的数据时,Cloud 层会在第 6 步直接拦截,报错 database permission denied

三、 使用详解

要实现小程序直接调用云数据库,需遵循以下步骤。

1. 环境初始化

在调用任何云能力之前,必须先初始化。

// app.js
App({
  onLaunch: function () {
    if (!wx.cloud) {
      console.error('请使用 2.2.3 或以上的基础库以使用云能力');
    } else {
      wx.cloud.init({
        // env 参数说明:
        //   env: 'your-env-id' // 云开发环境ID,可在云开发控制台获取
        traceUser: true, // 自动上报用户信息
      });
    }
  }
});

2. 获取数据库引用

const db = wx.cloud.database();
// 指定特定环境(如果有多个环境)
// const db = wx.cloud.database({ env: 'your-env-id' });

3. CRUD 操作示例

(1) 增 - Insert

// 添加数据
db.collection('todos').add({
  data: {
    description: '学习云开发',
    due: new Date('2023-12-31'),
    tags: ['cloud', 'database'],
    location: new db.Geo.Point(113, 23), // 地理位置
    done: false
  }
})
.then(res => {
  console.log('添加成功,记录ID:', res._id);
})
.catch(err => {
  console.error('添加失败', err);
});

(2) 查 - Query

// 获取数据
db.collection('todos').where({
  _openid: 'xxx' // 此处通常不需要手动填,如果开启了安全规则,系统会自动校验
})
.get()
.then(res => {
  // res.data 是一个数组
  console.log('查询结果:', res.data);
});
// 获取单条记录
db.collection('todos').doc('record-id-here').get()
.then(res => {
  console.log(res.data);
});

(3) 改 - Update

注意:update 只能修改符合 where 条件或通过 doc 指定的记录。

db.collection('todos').doc('record-id-here').update({
  data: {
    done: true // 将 done 字段改为 true
  }
})
.then(res => {
  console.log('更新成功,影响行数:', res.stats.updated);
});

(4) 删 - Remove

注意:在小程序端直接调用 remove 删除多条记录通常受限制,建议一次删除一条或使用云函数批量删除。

db.collection('todos').doc('record-id-here').remove()
.then(res => {
  console.log('删除成功');
});

4. 权限管理(安全规则)- 核心中的核心

小程序直接调用数据库之所以安全,是因为数据库安全规则。 在微信开发者工具 -> 云开发控制台 -> 数据库 -> 选择集合 -> 权限设置,有以下常见模式:

  • 仅创建者可写,所有人可读:
    • 适合:文章、帖子、评论。
    • 原理:系统自动检查记录中的 _openid 是否与当前用户的 _openid 一致。
  • 仅创建者可读写:
    • 适合:个人隐私数据(如购物车、个人设置)。
  • 所有人可读,仅创建者可写:
    • 适合:字典数据、配置数据。
  • 自定义安全规则:
    • 使用 JSON 语法定义复杂的逻辑。例如:"read": true, "write": "auth.openid == doc._openid"

5. 数据类型支持

云数据库支持丰富的数据类型,不同于传统的 MySQL,它直接支持:

  • GeoJSON: 地理位置点 db.Geo.Point,支持地理位置查询(如查找附近的人)。
  • Date: 时间对象 new Date()
  • Null: 空值。
  • 嵌套对象: JSON 对象多层嵌套。

四、 总结

1.微信小程序直接调用云数据库 的核心优势在于:

  1. 开发效率高: 省去了搭建服务器、编写 API 接口、维护数据库连接池的工作。
  2. 安全性强: 通过微信底层鉴权和安全规则,实现了前端直接操作数据库且不泄露数据。
  3. 成本低: 按量付费,对于中小型应用极其友好。

2.适用场景:

  • 快速原型开发(MVP)。
  • 逻辑相对简单的 CRUD 应用(如备忘录、简单的商城、预约系统)。
  • 企业内部员工或者B端项目多为表单提交,列表详情展示类(用户量较小) 不适用场景:
  • 复杂的事务处理(如涉及多表关联、复杂的金钱流转逻辑)。
  • C端日活高应用,高并发、高吞吐量的写操作(小程序端有连接数和频率限制)。
  • 需要高度保密的计算逻辑(逻辑放在前端容易被反编译,此时应使用云函数)。

elpis总结——基于koa的elpis-core

作者 striver
2026年3月6日 15:52

什么是 elpis-core ?

elpis-core 是对 koa 应用框架的一层封装,是精简版的 Node.js 框架;本质就是应用加载器,可以通过内部的 loader 加载代码,这块代码必须是按照约定的方式目录结构以及格式来写的。也可以理解为是精简版的 egg.js 。

elpis-core 都做了什么?

elpis-core 本质上是一个"应用加载器";

  1. 初始化 koa 实例
  2. 按照约定自动加载业务代码(controller、service、middleware、... 等)
  3. 统一管理配置和环境
  4. 启动 HTTP 服务

elpis-core 文件结构

elpis-core/
├── index.js          # 入口文件
├── env.js            # 环境判断
├── utils.js          # 工具函数
└── loader/           # 各类加载器
    ├── config.js     # 配置加载器
    ├── controller.js # 控制器加载器
    ├── service.js    # 服务层加载器
    ├── middleware.js # 中间件加载器
    ├── router.js     # 路由加载器
    ├── router-schema.js # 路由参数校验 Schema 加载器
    └── extend.js     # 扩展加载器

loader 作用

loader 主要是把项目文件夹内容挂载到 koa 实例,目的就是实现分层架构,解耦模块间的依赖,每个模块都统一访问入口在 app 实例上访问。

项目文件结构

config/              → 项目配置
app/ 
├── controller/      → 处理请求、调用 service、返回响应
├── service/         → 业务逻辑层
├── middleware/      → 中间件(鉴权、日志、错误处理等)
├── router/          → 路由定义
├── router-schema/   → 路由校验规则
└── extend/          → 扩展 app 能力(如 logger)
模块 作用
configLoader config/config.{env}.js 文件内容挂载到koa实例
controllerLoader 自动扫描 app/controller/**/*.js,并挂载koa实例
middlewareLoader 自动扫描 app/middleware/**/*.js,并挂载koa实例
serviceLoader 自动扫描 app/service/**/*.js,挂载koa实例
routerSchemaLoader 加载 app/router-schema/*.js,挂载到koa实例,用于 API 参数校验
routerLoader 自动扫描 app/router/**/*.js,注册到 koa-router,并挂载koa实例
extendLoader 加载app/extend/*.js,直接扩展 app 实例

image.png

koa 的洋葱圈模型

elpis-core 是对 koa 的封装,其中间件的执行顺序也是洋葱圈模型;请求从外层中间件进入,一层层向内传递,到达核心后再一层层向外返回。类似栈结构的先进后出,先进入的中间件最后也会在路过一次。

image.png

总结

通俗的来讲 elpis-core 就是项目的启动器,来启动按照我们约定的规则编写的项目,也就是极简版egg.js。

响应式探秘:ref vs reactive,我该选谁?

作者 wuhen_n
2026年3月5日 09:22

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

const target = { name: 'Vue' }
const handler = {
  get(target, key, receiver) {
    console.log(`读取属性: ${key}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    console.log(`设置属性: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
}

const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3

Proxy 的强大之处

  • 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
  • 动态属性响应:新增属性也能被追踪
  • 数组方法拦截:push、pop 等方法也能触发更新

关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。

reactive 的实现原理

reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:

// 简化的 reactive 实现
function reactive(target) {
  // 创建 Proxy 代理
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver)
      // 触发更新
      trigger(target, key)
      return result
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key)
      // 删除属性也要触发更新
      trigger(target, key)
      return result
    }
  })
}

// 使用
const state = reactive({
  count: 0,
  user: { name: '张三' }
})

state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理

ref 的实现原理

ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:

// 简化的 ref 实现
function ref(value) {
  // 创建包装对象
  const wrapper = {
    value: value
  }
  
  // 将包装对象变为响应式
  return reactive(wrapper)
}

// 更接近真实源码的实现
class RefImpl {
  constructor(value) {
    this._value = value
    this.__v_isRef = true // 标记这是一个 ref
  }
  
  get value() {
    // 依赖收集
    track(this, 'value')
    return this._value
  }
  
  set value(newVal) {
    if (this._value !== newVal) {
      this._value = newVal
      // 触发更新
      trigger(this, 'value')
    }
  }
}

function ref(value) {
  return new RefImpl(value)
}

// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问

从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!

关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。

ref vs reactive 的核心区别

访问方式:.value 的有无

这是两者最直观的区别:

import { ref, reactive } from 'vue'

// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1

// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1

重新赋值:整体替换 vs 属性修改

这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:

// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }

// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 } 

// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20

// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行

类型推导与解构

reactive 在使用解构时也会出现问题:

const state = reactive({
  name: '张三',
  age: 18,
  profile: {
    city: '北京'
  }
})

// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的

// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问

// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新

ref 在这方面的表现就很好:

// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

const refObj = ref({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新

const reactiveObj = reactive({
  user: {
    name: '张三',
    address: {
      city: '北京'
    }
  }
})

// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新

什么时候用 ref?

基础类型值

这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:

const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

// 表单数据,经常需要重置
const formData = ref({
  username: '',
  email: '',
  password: ''
})

// 重置表单 - ref 轻松搞定
function resetForm() {
  formData.value = {
    username: '',
    email: '',
    password: ''
  }
}

// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
  const data = await api.getForm(id)
  formData.value = data // ✅ 直接替换
}

当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:

// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

export function useUser() {
  const user = ref(null)
  const loading = ref(false)
  const error = ref(null)
  
  async function fetchUser(id) {
    loading.value = true
    try {
      user.value = await api.getUser(id)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  // 返回 ref 对象,使用者可以随意解构
  return {
    user,
    loading,
    error,
    fetchUser
  }
}

// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常

跨组件传递时的类型安全

当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:

<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>

<template>
  <ChildComponent :data="userData" />
</template>

<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
  data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()

// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>

获取子组件实例

当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据: 子组件 Child.vue

<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

<template>
  <!-- 子组件 -->
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

// 创建一个ref来存储子组件实例
const childRef = ref(null)

// 调用子组件方法
const callChildMethod = () => {
  if (childRef.value) {
    childRef.value.childMethod()  // 调用子组件暴露的方法
    console.log(childRef.value.childData)  // 访问子组件暴露的数据
  }
}

// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
  console.log('子组件实例:', childRef.value)
})
</script>

什么时候用 reactive?

深层嵌套的对象

当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:

// 复杂的状态对象
const store = reactive({
  user: {
    profile: {
      personal: {
        name: '张三',
        age: 18
      },
      contact: {
        email: 'zhang@example.com',
        phone: '1234567890'
      }
    },
    preferences: {
      theme: 'dark',
      language: 'zh-CN',
      notifications: {
        email: true,
        sms: false
      }
    }
  },
  ui: {
    sidebar: {
      collapsed: false,
      width: 240
    },
    modal: {
      visible: false,
      type: null
    }
  }
})

// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true

// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
  // 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value

不需要整体替换的数据

对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:

const appConfig = reactive({
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retryCount: 3,
  features: {
    logging: true,
    cache: false
  }
})

// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
  get(target, key) {
    console.log(`读取 ${key}`)
    return target[key]
  },
  set(target, key, value) {
    console.log(`设置 ${key} = ${value}`)
    target[key] = value
    return true
  }
})

此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 name 的操作都只是修改一个普通变量,不会触发任何拦截

解决方案

方案一:使用 toRefs(推荐)

import { reactive, toRefs } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

function toRefs(obj) {
  const result = {}
  // 遍历对象的所有key
  for (const key in obj) {
    result[key] = toRef(obj, key) // 为每个属性单独创建 ref
  }
  return result
}

// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'

方案二:使用 toRef 处理单个属性

import { reactive, toRef } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')

name.value = '李四' // ✅ 触发更新

方案三:直接用 ref

如果发现需要频繁解构,可能在一开始就应该使用 ref

const user = ref({
  name: '张三',
  age: 18
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景 推荐方案 原因
基础数据类型 ref reactive 无法处理基础类型
需要整体重置的表单 ref 支持直接替换 .value
组合式函数返回值 ref 方便使用者解构
复杂嵌套对象 reactive 语法更简洁
一次性初始化配置 reactive 不需要整体替换
需要解构的场景 ref + toRefs 保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

前端架构演进与模块化设计实践

2026年3月5日 09:18

 引言:从"能运行"到"好维护"的转变

在快速迭代的业务需求面前,我们是否经常遇到这样的场景:新功能不敢轻易开发,因为担心影响现有业务;代码修改牵一发而动全身;不同业务模块间耦合严重,难以独立部署和测试。这些问题背后,反映的是前端架构设计的重要性。

1. 架构设计的核心目标

1.1 可持续性

  • 代码应易于理解和扩展
  • 新成员能够快速融入开发
  • 技术债务可控

1.2 可维护性

  • 模块职责清晰明确
  • 变更影响范围可控
  • 调试和定位问题高效

1.3 可测试性

  • 组件能够独立测试
  • 模拟各种业务场景
  • 自动化测试覆盖核心流程

2. 现代前端架构模式实践

2.1 分层架构设计

whiteboard_exported_image.png

实践案例:用户管理模块

// 表现层 - UserList.tsx
const UserList: React.FC = () => {
  const { users, loading, error } = useUserManagement();
  
  if (loading) return <Loading />;
  if (error) return <Error message={error} />;
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};

// 业务层 - useUserManagement.ts
export const useUserManagement = () => {
  const [state, setState] = useState<UserState>(initialState);
  
  const fetchUsers = async () => {
    try {
      setState(prev => ({ ...prev, loading: true }));
      const users = await userAPI.fetchAll();
      setState(prev => ({ ...prev, users, loading: false }));
    } catch (error) {
      setState(prev => ({ ...prev, error: error.message, loading: false }));
    }
  };
  
  return { ...state, fetchUsers };
};

// 数据层 - userAPI.ts
export const userAPI = {
  fetchAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    return response.json();
  }
};

2.2 微前端架构实践

场景:大型管理平台,多个团队协作开发

解决方案:

// 主应用 - 路由配置
const routes = [
  {
    path: '/order/*',
    component: () => import('order-app/OrderModule'),
  },
  {
    path: '/user/*', 
    component: () => import('user-app/UserModule'),
  },
  {
    path: '/product/*',
    component: () => import('product-app/ProductModule'),
  }
];

// 模块联邦配置 - webpack.config.js
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        orderApp: 'order@http://localhost:3001/remoteEntry.js',
        userApp: 'user@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true },
        'react-dom': { singleton: true }
      }
    })
  ]
};

3. 模块化设计原则

3.1 单一职责原则

// ❌ 违反单一职责
class UserService {
  async getUser(id: string) { /* ... */ }
  validateEmail(email: string) { /* ... */ }
  sendEmail(content: string) { /* ... */ }
  formatUserData(user: User) { /* ... */ }
}

// ✅ 符合单一职责
class UserRepository {
  async getUser(id: string) { /* ... */ }
}

class ValidationService {
  validateEmail(email: string) { /* ... */ }
}

class EmailService {
  sendEmail(content: string) { /* ... */ }
}

class UserFormatter {
  formatUserData(user: User) { /* ... */ }
}

3.2 依赖倒置原则

// 定义抽象接口
interface UserStorage {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// 具体实现
class LocalStorageUser implements UserStorage {
  async save(user: User) {
    localStorage.setItem(`user_${user.id}`, JSON.stringify(user));
  }
  
  async findById(id: string) {
    const data = localStorage.getItem(`user_${id}`);
    return data ? JSON.parse(data) : null;
  }
}

class APITUserStorage implements UserStorage {
  async save(user: User) {
    await fetch('/api/users', {
      method: 'POST',
      body: JSON.stringify(user)
    });
  }
  
  async findById(id: string) {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  }
}

// 业务逻辑依赖于抽象,而非具体实现
class UserService {
  constructor(private storage: UserStorage) {}
  
  async updateUser(user: User) {
    // 业务逻辑
    await this.storage.save(user);
  }
}

4. 状态管理架构演进

4.1 状态分类与管理策略

// 1. 本地状态 - 使用 useState/useReducer
const [formData, setFormData] = useState(initialFormData);

// 2. 全局状态 - 使用 Zustand(推荐轻量级方案)
const useUserStore = create((set, get) => ({
  users: [],
  loading: false,
  fetchUsers: async () => {
    set({ loading: true });
    const users = await userAPI.fetchAll();
    set({ users, loading: false });
  },
  addUser: (user: User) => {
    set(state => ({ 
      users: [...state.users, user] 
    }));
  }
}));

// 3. 服务端状态 - 使用 React Query/SWR
const { data: users, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  staleTime: 5 * 60 * 1000, // 5分钟
});

4.2 状态规范化

//  嵌套深、难以更新的状态
const state = {
  posts: [
    {
      id: 1,
      title: 'Post 1',
      author: {
        id: 1,
        name: 'John',
        avatar: '...'
      },
      comments: [
        {
          id: 1,
          text: 'Great!',
          user: {
            id: 2,
            name: 'Alice'
          }
        }
      ]
    }
  ]
};

//  规范化状态
const normalizedState = {
  posts: {
    byId: {
      1: { id: 1, title: 'Post 1', author: 1, comments: [1] }
    },
    allIds: [1]
  },
  users: {
    byId: {
      1: { id: 1, name: 'John', avatar: '...' },
      2: { id: 2, name: 'Alice' }
    },
    allIds: [1, 2]
  },
  comments: {
    byId: {
      1: { id: 1, text: 'Great!', user: 2, post: 1 }
    },
    allIds: [1]
  }
};

5. 构建可测试的架构

5.1 依赖注入与测试

// 业务逻辑
class OrderService {
  constructor(
    private paymentGateway: PaymentGateway,
    private notificationService: NotificationService,
    private inventoryService: InventoryService
  ) {}
  
  async processOrder(order: Order) {
    // 1. 扣减库存
    await this.inventoryService.reserve(order.items);
    
    // 2. 处理支付
    const paymentResult = await this.paymentGateway.charge(order.total);
    
    // 3. 发送通知
    if (paymentResult.success) {
      await this.notificationService.sendOrderConfirmation(order);
    }
    
    return paymentResult;
  }
}

// 单元测试
describe('OrderService', () => {
  it('should process order successfully', async () => {
    // 准备测试替身
    const mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ success: true })
    };
    const mockNotificationService = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(undefined)
    };
    const mockInventoryService = {
      reserve: jest.fn().mockResolvedValue(undefined)
    };
    
    // 创建被测试实例
    const orderService = new OrderService(
      mockPaymentGateway,
      mockNotificationService,
      mockInventoryService
    );
    
    // 执行测试
    const result = await orderService.processOrder(testOrder);
    
    // 验证行为
    expect(result.success).toBe(true);
    expect(mockInventoryService.reserve).toHaveBeenCalledWith(testOrder.items);
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith(testOrder.total);
    expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(testOrder);
  });
});

6. 架构质量度量与改进

6.1 代码质量指标

// 使用 ESLint 插件监控架构质量
module.exports = {
  rules: {
    'max-dependencies': ['error', 10], // 单个模块最大依赖数
    'cyclic-dependency': 'error',       // 禁止循环依赖
    'no-relative-import': 'error',      // 禁止相对导入
    'feature-envy': 'error'             // 禁止特性依恋
  }
};

// package.json 依赖治理
{
  "scripts": {
    "analyze:deps": "madge --image deps-graph.svg src/",
    "analyze:complexity": "complexity-report src/",
    "check:circular": "dpdm --circular src/**/*.ts"
  }
}

7. 结语

好的前端架构不是一蹴而就的,而是随着业务发展和团队成长不断演进的过程。它需要在过度设计与缺乏设计之间找到平衡,在满足当前需求的同时为未来变化留出空间。

架构的终极目标不是构建完美的系统,而是创建能够优雅演进的系统。希望本文的实践经验能够为团队在架构设计方面提供有价值的参考,欢迎大家共同探讨和改进我们的前端架构实践。

8. 团队介绍

智慧家技术平台-应用软件框架开发」主要负责设计工具的研发,包括营销设计工具、家电VR设计和展示、水电暖通前置设计能力,研发并沉淀素材库,构建家居家装素材库,集成户型库、全品类产品库、设计方案库、生产工艺模型,打造基于户型和风格的AI设计能力,快速生成算量和报价;同时研发了门店设计师中心和项目中心,包括设计师管理能力和项目经理管理能力。实现了场景全生命周期管理,同时为水,空气,厨房等产业提供商机管理工具,从而实现了以场景贯穿的B端C端全流程系统。

Cursor 的 5 种指令方法比较,你最喜欢哪一种?

作者 Moment
2026年3月5日 08:56

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

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

在使用 Cursor 时,你会看到很多向 AI 发指令的方式:AGENTS.md.cursor/rules/.cursor/commands/.cursor/skills/.cursor/agents/(子代理)。每种方式适用场景不同,不少人会问:"该用哪一个?怎么区分?" 这篇文章把它们的用法和适用场景做个对比,并给出选择思路和组合建议,方便你按需搭配。

读完后你可以做到三件事:快速判断新需求该用哪种方式、避免把规则写错位置、用一套合理的目录结构起步。

为什么需要多种指令方式

当你希望 Cursor 用 Next.js、TypeScript 做 Web 应用时,往往会加上一堆约定,比如:

  • API 路由必须用 zod 做校验
  • 数据库访问用 Prisma 的类型安全写法
  • 错误统一成固定格式返回

这些规则在一次对话里 AI 还能记住,但新开一个会话又要重新说一遍。原因在于 LLM 本身的限制:

  • 无状态:不会跨会话记忆
  • Token 有限:上下文窗口有上限
  • 成本:上下文越长,调用成本越高

所以我们需要把"要说什么"沉淀成文件,并在合适的时机让 AI 读到。不同文件类型对应不同的触发时机和复用范围,这就是五种方式存在的理由。其中 Skills 专门针对"知识很多但不想一次全塞进上下文"的问题,用渐进式披露来平衡信息量和 Token。

渐进式披露(Progressive Disclosure)的意思是:需要的时候再把相关知识装进上下文,信息分阶段给出去,既够用又不占满上下文。Skills 的三级加载大致如下。

这部分原本用流程图展示三个级别之间的关系,下面是可以直接复制到文生图应用里的提示语。

20260305084854

  • 级别 1(元数据):所有已安装技能的 namedescription 会预先放进系统提示,让代理知道有哪些技能、大概干什么,从而判断和当前任务是否相关。
  • 级别 2(说明):一旦判定相关,再把该技能的 SKILL.md 全文载入,看清具体步骤和指示。
  • 级别 3(资源):若执行任务还需要更多信息,再按 SKILL.md 里的引用去打开其他文件、脚本或资料。

这样可以把大量信息收进技能里,又不会一次性占满 Token,这就是 Skills 的设计思路。

5 种方法概览

下面用几张表把五种方式的基本信息、适用时机和主要用途捋一遍。

基本信息

项目 AGENTS.md Rules Commands Skills Subagents
保存位置 项目根目录 .cursor/rules/ .cursor/commands/ .cursor/skills/ .cursor/agents/
文件格式 AGENTS.md .mdc.md .md 文件夹内 SKILL.md .md
运营重要性 推荐 强烈推荐 任意 推荐 任意
利用频率 较高 极高 中等 中等 较低

适用时机与上下文

项目 AGENTS.md Rules Commands Skills Subagents
适用时机 始终自动 条件、常时或手动 仅手动、通过命令名 AI 自动判断或手动、技能名 AI 自动判断或手动
上下文 与父共享 与父共享 与父共享 与父共享 独立上下文
并行执行 不可 不可 不可 不可 可(多子代理同时跑)

再利用性

项目 AGENTS.md Rules Commands Skills Subagents
再利用性 项目内(单体仓库可在子目录各配一份) 项目内 项目内 可跨项目(可全局配置) 可跨项目(可全局配置)

主要用途

项目 AGENTS.md Rules Commands Skills Subagents
主要目的 项目整体方针 对 AI 的持续指示 特定作业的一键启动 专业能力扩展 任务委托与并行
适合场景 简单、整体的指示 需要按条件生效的规则 人工触发的固定流程 通用领域知识 长时间调研、并行处理
示例 编码风格、架构原则 某目录下的规则 /code-review GraphQL 设计最佳实践 安全审查、验证作业

如何快速选:一张流程图

遇到新需求时,可以按一张决策流程图先判断用哪种方式,再去看对应章节的细节。下面是给文生图应用使用的提示语。

20260305084956

若一个需求同时符合多种方式(例如既有"始终生效的简略约定"又有"按目录生效的细则"),可以组合使用,例如根目录 AGENTS.md 写总纲,再用 Rules 针对 src/components/ 写组件规范。

AGENTS.md:项目的「README 式总纲」

AGENTS.md 是五种方式里最省事、门槛最低的一种,可以理解成「写给 Cursor 看的项目 README」。只要在项目根目录放一个 AGENTS.md,当你在这个项目里向 Cursor 提问时,它会自动把这里的内容当成项目的默认约定和背景信息。

在 Cursor 里的效果大致如下:

20260227142857

上面这张图对应的实际配置非常简单:我只是在项目根目录新建了一个 AGENTS.md,里面写清楚「开发或调试前要先跑哪几个命令」:

## 项目开发指令(AGENTS.md)

每次开发或调试前,请按顺序执行以下三个命令:

1. `pnpm dev`:启动开发服务器。
2. `pnpm build`:打包生产环境构建,确保构建无误。
3. `pnpm view react version`:查看当前 npm 注册表中的 React 最新版本号。

之后在这个项目里,只要我随手问一句「帮我优化一下这段代码」之类的问题,Cursor 在后台都会先读取这个 AGENTS.md,于是你在右侧看到的,就是「先按这三个步骤来」这种带项目语境的回答——相当于让 AGENTS.md 帮你把团队约定自动说了一遍。

特点:

  • 零配置:根目录放一个文件即可生效,不需要再写任何额外设置
  • 可读性好:纯 Markdown,适合写简洁、叙事化的项目方针和协作约定
  • 可分区:单体仓库可以在子目录各放一份(例如 frontend/AGENTS.mdbackend/AGENTS.md),分别约束前端、后端
  • 始终生效:对整个项目长期生效,适合放「总纲型」规则,而不是细碎的按场景说明
  • 易维护:内容有变更时,直接编辑本文件即可,无需动其他配置

下面是一个简化示例,用来说明"项目指令"长什么样、一般会写哪些内容:

# Project Instructions

## Code Style

- 使用 TypeScript
- 优先函数型组件

## Architecture

- 使用 Repository 模式
- 业务逻辑放在服务层而不是组件里

适合用 AGENTS.md 的情况:只需要一份项目级总纲,不强调按条件生效,也暂时不考虑跨项目复用,只想用最少配置写清楚项目整体约定和团队共识。一旦要"某些情况才生效"、"手动触发一套流程"或"多个项目共用同一套知识",就该考虑 Rules、Commands 或 Skills。

另外要注意:AGENTS.md 的内容会在每次相关对话里加载,写得太长会一直占用上下文。更细碎、按场景区分的规则,更适合拆到 Rules 或 Skills 里去。

规则(Rules):细一点、灵活一点的背景指令

当规则变多、需要按条件生效,或者想针对某些文件、目录单独约定时,就用 Rules。它可以按路径、glob 或"是否常驻"来选什么时候生效,还能在规则里用 @filename 引用具体文件。

特点:

  • 适用条件可配置:常时、特定文件、由 AI 判断或手动选
  • 支持在规则里引用代码,例如 @component-template.tsx
  • 多个规则文件放在 .cursor/rules/ 里,便于分类
  • 团队可在仪表板统一管理

Rules 的 frontmatter 里常用字段有:description(规则说明,供 AI 或人工识别)、globs(匹配哪些路径)、alwaysApply(是否常驻)。不写 globsalwaysApply: true 时,规则会在所有对话里生效。

例如给 React 组件单独定一套规则,只在编辑 src/components/ 下的 .tsx 时生效。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文。下面先给出 YAML 头,用于指定描述和匹配范围:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

同一文件内,frontmatter 后面接 Markdown 正文,写具体规则内容并引用模板文件:

# React Component Rules

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

这样在编辑 src/components/ 下文件时,这条规则才会被应用。若希望某条规则在任意对话里都生效(例如全局编码风格),可设 alwaysApply: true 且不设 globs

命令(Commands):一键跑完的流程

Commands 用来把"一串固定操作"打包成一个命令,由你手动触发,比如跑测试、做 code review、建 PR。每个命令对应一个文件,通过 /命令名 调用。

特点:

  • 通过 / 即调即执行
  • 可以带参数,例如 /commit/pr for DX-523
  • 团队可在仪表板共享同一套命令
  • 把多步操作写在一个命令里,减少重复说明

例如 /code-review:在 .cursor/commands/code-review.md 里定义步骤,之后在聊天里输入 /code-review 就会按这些步骤执行。下面给出该文件的示例内容,用于说明"命令文件"如何写步骤:

# /code-review

## 步骤

1. 确认变更的文件
2. 检查安全问题
3. 确认是否符合编码规范
4. 列出 3 个改进提案

使用方式:在输入框输入 /code-review,必要时加一句说明,例如:"请审查这个 PR"。

Commands 适合:测试、代码审查、建 PR 这类需要人工点一下才跑的固定流程。不适合:希望自动生效、一直挂在上下文里、或由 AI 自己决定何时用的规则与知识,那些更适合 AGENTS.md、Rules 或 Skills。

和子代理的区别:Commands 是在当前对话里"插入一段预设步骤",上下文还是主对话;子代理是另开一个独立对话去执行,结果再回传,适合耗时长或需要并行的任务。

技能(Agent Skills):可复用的专业知识包

Agent Skills 把某一类专业知识打成一个个「能力模块」,用开放标准格式(含 SKILL.md 和 YAML 头)描述。AI 先只知道有哪些技能(name / description),真正需要时再把对应技能的全文与资源加载进来,这就是 Skills 的渐进式、模块化加载。

特点(和 Rules 的区别可以顺便看出来):

  • 按知识主题模块化:每个 Skill 代表一块专业能力(如组件命名规范、GraphQL 设计),可以在多个项目里复用
  • 按需加载:所有技能只先加载元数据,只有和当前任务相关的那几个才会把 SKILL.md 正文装进上下文,后续再按需加载引用资源
  • 规则 vs 技能:Rules 是按「当前在哪个文件 / 路径」选出要生效的规则,一旦命中就整条规则全文进上下文;Skills 则是先全局感知有哪些技能,再对当前任务只挑相关的那几块知识细化加载
  • 可组合脚本:Skill 里还可以挂脚本,把 LLM 不擅长的步骤交给代码执行

实现上,每个技能是一个文件夹,里面至少有一个 SKILL.md,并在文件开头用 YAML 定义 namedescription。Cursor 启动时会扫描技能目录(例如 .cursor/skills/),先把所有技能的元数据提供给代理,之后在具体任务里再决定要不要加载某个技能的正文。

下面直接用本文正在使用的真实示例来说明 Skill 的长相:我在这个项目里新建了 .cursor/skills/moment-component-prefix/SKILL.md,约定所有 React 组件的名字都以 Moment 开头(例如 MomentButtonMomentCard)。完整内容如下:

---
name: Moment 组件命名规范
description: 要求本项目中所有 React 组件名称都以 Moment 开头,例如 MomentButton、MomentCard。
---

# Moment 组件命名技能

## 何时使用

- 在创建新的 React 组件时
- 在重命名或抽取组件时

## 指示

- 所有导出的 React 组件名称必须以 `Moment` 开头,例如 `MomentHeader``MomentFooter`- 若根据文件名生成组件,组件名也应以 `Moment` 开头,例如文件 `header.tsx` 中的组件名为 `MomentHeader`- 执行重构时,如遇不符合该前缀的组件,应优先建议重命名为带 `Moment` 前缀的版本。

配置好这个 Skill 之后,当我在项目里请求「优化组件」「重构组件名」时,Cursor 就会自动参考这份规则,优先给出带 Moment 前缀的组件名称。下图展示的是这个 Skill 实际生效时的效果:

20260227144513

子代理(Subagents):只干一件事的副手

子代理可以理解成「由主代理派出的专职小助手」。每个子代理都有独立上下文,专注处理某一类任务,做完以后把结果打包交回主代理;主代理再根据这些结果继续和你对话或接着修改代码。

模块化的角度看,子代理不是按「路径」或「知识主题」来拆,而是按任务角色来拆:比如「验证助手」「安全审查助手」「UI 回归助手」等,让每个子代理只干一件事、长期保持同一种工作风格。

特点:

  • 上下文隔离:子代理有自己的对话历史,不会把长时间验证 / 调研的细节塞进你的主对话里
  • 可并行:可以同时开多个子代理,让它们各自跑不同任务,再统一收结果
  • 可定制:每个子代理可以配自己的提示词、工具组合和模型类型
  • 可复用:同一子代理配置可以在多个项目共享,比如统一的安全审查或验证流程

调度方式大致有两种:

模式 行为 适合场景
Foreground 主代理等子代理跑完再继续对话 需要按顺序拿到输出的任务(例如「先验证再继续开发」)
Background 立刻把控制权还给你,子代理在后台慢慢跑 耗时长或希望并行多任务时(例如「一边写代码,一边让子代理做全面安全审查」)

需要注意的是:子代理从「空上下文」开始,看不到主对话历史。所以主代理在派活时,必须把当前任务描述、涉及的文件、期望的检查点等一起打包进提示,否则子代理只好猜,很容易跑偏。

下面是本文当前项目里真实在用的一个「验证」子代理配置,文件路径是 .cursor/agents/verifier.md,主要负责在改动完成后帮忙做一次系统性的检查与建议:

---
name: verifier
description: 验证已完成的工作,检查实现是否按预期运作,并结合本项目约定给出测试与改进建议。
---

# Verifier 子代理

你是本项目的验证助手,专门在实现完成后进行检查与验证。你有独立上下文,不会看到主对话历史,所有必要信息会由主代理在派发任务时提供给你。

## 目标

- 确认实现是否满足用户需求与设计意图。
- 检查是否存在明显的类型错误、运行时错误或边界情况缺失。
- 结合本项目使用的工具链(pnpm、React、TypeScript)给出合理的测试与改进建议。

## 使用背景

- 本项目使用 pnpm 作为包管理工具。
- 运行开发与构建相关的典型命令包括但不限于:
  - `pnpm dev`
  - `pnpm build`
  - (如存在)`pnpm test``pnpm lint`

## 验证步骤

1. **理解需求与范围**
   - 阅读主代理提供的任务描述与变更说明。
   - 弄清楚本次改动的功能边界与非目标范围。

2. **审查代码与结构**
   - 聚焦主代理列出的关键文件和模块。
   - 检查是否遵循项目的技术栈约定(React + TypeScript 等)。
   - 粗略评估实现是否过于复杂,是否可以简化。

3. **测试与构建建议**
   - 若主代理提供了命令输出(如 `pnpm build``pnpm test`),分析其中的错误、警告与提示。
   - 若尚未执行相关命令,明确建议主代理或用户执行:
     - 至少运行 `pnpm build` 以确认生产构建是否通过。
     - 若项目配置了测试与 Lint,建议运行 `pnpm test``pnpm lint`4. **边界条件与错误处理检查**
   - 思考与本次改动相关的典型边界情况(空数据、错误响应、慢网路、异常输入等),检查代码中是否有所体现。
   - 指出潜在的未处理情况或容易出错的分支,并给出改进方向。

5. **输出结构化报告**
   - 列出:
     - ✅ 已验证通过的项目(功能点、测试或构建检查)。
     - ⚠️ 发现的问题(含严重程度说明)与改进建议。
     - ❓ 需要主代理或用户补充的信息(例如缺失的日志、命令输出、接口约定等)。
   - 报告尽量简洁、条理清晰,便于主代理直接据此继续修改或补充验证。

## 与主代理的协作约定

- 若信息不足以完成可靠验证,请明确说明缺口,并请求主代理补充必要上下文,而不是进行过度臆测。
- 在可能的情况下,优先给出可操作的、一步步的改进建议,而不是泛泛而谈。

实际使用时,你可以在主对话里完成一轮实现,然后让主代理把相关改动、运行命令和期望行为打包交给 verifier 子代理,让它在独立上下文里跑完整套验证,并把结果以结构化报告的形式带回主对话。这样既不污染主对话的上下文,又把「验证这件事」模块化成了一个可以在多个项目反复复用的助手。

实际使用示例:React 项目里的五种用法

同一个需求"在 React 项目里统一组件开发方式",可以分别用五种方式实现。下面按"从简到繁、从项目内到可复用"的顺序各给一个写法,便于对比同一诉求在不同方式下的形态。

方式 1:AGENTS.md(最简)

在项目根目录的 AGENTS.md 里写一段即可,适合小项目或只做简单约定时使用。

# Project Instructions

## React 组件开发

- 使用 TypeScript
- 优先函数型组件
- Props 必须进行类型定义

方式 2:Rules(按目录生效)

希望规则只在改 src/components/ 时生效,可以用 Rules。在 .cursor/rules/react-components.mdc 里写 frontmatter 和正文,例如引用模板文件:

---
description: "React 组件开发的规则"
globs: ["src/components/**/*.tsx"]
alwaysApply: false
---

# React 组件规则

## 组件创建时

- 必须定义 Props 接口
- 避免默认 export
- 参考 @component-template.tsx

方式 3:Commands(手动执行一套步骤)

把"创建组件"拆成固定步骤,做成命令 /create-component。在 .cursor/commands/create-component.md 里写:

# /create-component

# 步骤

1. 创建组件文件
2. 定义 Props 接口
3. 创建测试文件

使用:输入 /create-component Button,即可按步骤生成组件并带测试。

方式 4:Skills(可复用的最佳实践)

把 React 组件的最佳实践打成技能,AI 在写组件或做 code review 时会按需加载。例如 .cursor/skills/react-best-practices/SKILL.md

---
name: React Best Practices
description: React 组件开发的最佳实践。重视性能优化、重渲染防止、Hooks 的适当使用。
---

# React Best Practices 技能

## 何时使用

- 创建或修正 React 组件时
- 需要性能优化时
- 使用 Hooks 时
- 解决重渲染问题时

## 指示

### 组件设计

- 自定义 Hook 使用 `use` 前缀
- Props 接口必须进行类型定义
- 组件遵循单一责任原则

### 性能优化

- `useMemo``useCallback` 仅在必要时使用
- `useEffect` 的依赖数组必须明确指定
- 对于大型列表,考虑使用虚拟化

### 重渲染防止

- `memo` 仅在必要时使用(避免过度优化)
- Context 的值适当进行 Memoization
- 识别不必要重渲染的原因

适合:希望按"行业常见实践"自动参与编写和审查,且可能多个项目共用时。

方式 5:子代理(独立验证)

验证和测试单独交给子代理,不占用主对话上下文。例如 .cursor/agents/verifier-reviewer.md

---
name: verifier
description: 验证已完成的工作,确认实现是否正常运作,并执行测试
---

# Verifier 子代理

此子代理验证已完成的工作,确认实现是否正常运作,执行测试,并报告成功和未完成的部分。

## 验证步骤

1. 确认已实现的代码
2. 执行单元测试
3. 执行集成测试
4. 检查错误或警告
5. 报告结果

适合:做完一坨改动后,希望单独跑一轮验证或测试,而不把主对话拉得很长时。

小结:若只做"项目内、始终生效的简单约定"用方式 1;若希望"只在改组件目录时生效"用方式 2;若希望"人工点一下才按步骤生成组件"用方式 3;若希望"多项目共用、且 AI 写组件或审查时自动参考"用方式 4;若希望"验证和测试在独立上下文里跑"用方式 5。实际项目中往往组合使用,例如 1 + 2 + 4,或 1 + 3 + 5。

再举一个安全审查子代理的例子,放在 .cursor/agents/security-reviewer.md

---
name: security-reviewer
description: 检查代码中的注入、XSS、硬编码秘密等常见漏洞
---

# Security Reviewer 子代理

您是安全专家。执行代码的安全审查,识别潜在漏洞。

## 检查项目

1. SQL 注入
2. XSS(跨站脚本攻击)
3. 硬编码秘密
4. 认证和授权问题
5. 遵守安全的编码实践

从零开始的建议顺序

如果项目里还没用过这些方式,可以按这个顺序逐步加,避免一次堆太多导致维护成本高:

  1. 先写一个根目录的 AGENTS.md,把项目技术栈、编码风格、目录约定等总纲写清楚,控制在几十行以内。
  2. 再按目录或文件类型加 Rules,例如 src/components/ 用组件规则、src/api/ 用 API 规则,每条规则保持单一职责。
  3. 把经常重复的"多步操作"抽成 Commands,例如 /code-review/run-tests,方便团队统一流程。
  4. 若有跨项目共用的领域知识(如 GraphQL、无障碍、K8s),再做成 Skills,安装到 .cursor/skills/ 或全局技能目录。
  5. 子代理用在"需要独立上下文或并行"的场景即可,不必每个项目都配。

一个常用的项目配置结构

下面是一套常见的组合方式,按目录列出来,方便你直接套用或裁剪。如果希望用图来展示整个项目结构,可以使用下面这段提示语生成信息图。

手绘风格教育科普信息图海报,竖版 3:4 比例,白色或浅米色背景,彩色铅笔与素描线条质感,温暖柔和蓝黄粉绿橙配色,整体风格类似儿童编程科普图。顶部标题「一个典型 Cursor 项目的配置结构」,副标题「AGENTS、Rules、Commands、Skills、Subagents 分布一图看懂」。
画面中央是一棵抽象的项目目录「树」或卡片式结构:最上方是大文件夹「项目根目录」,内部画 `AGENTS.md` 文件卡片,旁边小标签「项目整体方针」。向下分出两条分支,分别是 `frontend/AGENTS.md` 和 `backend/AGENTS.md`,用不同颜色表示前端与后端,旁边写「前端指令」「后端指令」。
根目录下画 `.cursor` 大文件夹,内部再分为四个子文件夹:`rules`、`commands`、`skills`、`agents`,每个子文件夹用不同颜色和图标表示:
rules 区块下有 `api-design.mdc`、`database-schema.mdc`、`deployment-flow.mdc` 三个文件卡片,配注释「API 设计规则」「数据库设计规则」「部署流程」;
commands 区块下有 `code-review.md`、`create-pr.md`、`run-tests.md` 三个文件卡片,标注「代码审查命令」「创建 PR」「运行测试」;
skills 区块下有 `react-best-practices/` 文件夹和若干 `SKILL.md` 文件,如 `graphql-best-practices/SKILL.md`、`kubernetes-ops/SKILL.md`、`accessibility/SKILL.md`,配小图标代表前端、后端与运维知识,旁边写「可复用技能包」;
agents 区块下有 `verifier.md` 和 `security-reviewer.md` 两个文件卡片,配放大镜与盾牌图标,分别标注「验证子代理」「安全审查子代理」。
用细虚线或颜色分区轻轻框出「项目总纲(AGENTS)」「项目内规则(Rules)」「一键流程(Commands)」「领域技能(Skills)」「子代理(Agents)」五块区域,每块区域顶部有小标题和简短说明,整体布局紧凑清晰,方便一眼看懂各类文件放在哪里、负责什么。

常见误区和组合建议

  • 把本该"按条件生效"的细则写进 AGENTS.md,导致上下文总是很长。这类内容更适合放到 Rules(按路径)或 Skills(按任务类型)。
  • 把"希望 AI 自动用到"的领域知识只写在 Commands 里。Commands 只有你输入 /xxx 时才会执行,不会自动参与编写或审查,这类知识应放在 Rules(项目内)或 Skills(可复用)。
  • 同一件事既写 Rules 又写 Skills,内容重复且可能冲突。约定好边界:和本项目、本目录强相关的用 Rules,通用且要复用的用 Skills。
  • 子代理的提示里没带够上下文。子代理看不到主对话,主代理在派活时必须把"当前改了哪些文件、期望验证什么"等写进提示,否则子代理容易做无用功。

组合建议:多数项目用"根目录 AGENTS.md + 若干 Rules"就能覆盖大部分需求;Commands 按团队实际工作流补几条即可;Skills 和 Subagents 按是否有跨项目知识、是否有并行或独立验证需求再加。这样既不会漏掉该用的方式,也不会堆得难以维护。

总结一下选择思路:要"项目总纲"用 AGENTS.md;要"按文件或条件生效"用 Rules;要"人工一键跑流程"用 Commands;要"可复用的领域知识"用 Skills;要"独立上下文、并行或专门验证"用 Subagents。按需求组合这五样,就能把 Cursor 的指令体系用得比较顺手。

❌
❌