普通视图
ArcGIS Pro 中的 Python 入门
^ 关注我,带你一起学GIS ^
前言
❝
Python 脚本使自动化 ArcGIS Pro 中的工作流成为可能。
本教程来源于ESRI如何在ArcGIS Pro中学习使用Python。在本教程中,您将编写代码来确定工作空间中的所有要素类的要素数量。 这也介绍了Python语法的一些基础知识。 您将在ArcGIS Pro的Python窗口中编写代码。 可以将代码导出到Python脚本,该脚本可以在ArcGIS Pro外部打开、修改和运行。
文中以ArcGIS Pro3.5为例。
1. 开发环境
本文使用如下开发环境,以供参考。
时间:2026年
系统:Windows 11
ArcGIS Pro:3.5
Python:3.11.11
2. 数据准备
俗话说巧妇难为无米之炊,数据就是软件开发的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载GIS数据。
别急,GIS之路公众号都给你准备好了
在公众号后台回复关键字:vector,获取数据下载链接。![]()
3. ArcGIS Pro 查看数据集
在本教程中默认你已经学会使用ArcGIS Pro创建工程,并打开数据集。
打开population数据集,然后在图层上右键打开属性,可以在属性表中看到共有32条记录。![]()
4. 使用 Python 工具查看数据集
在ArcGIS Pro中运行地理处理工具,使用计数工具箱获取要素集数量。选择Analysis菜单栏,然后点击地理处理工具中的Tools。![]()
在出现的地理处理窗格搜索框中输入"count"进行检索,点击如下"Get Count"工具。![]()
在Input Rows参数中选择查询数据集population,点击运行。完成之后可以点击查看详情或者打开历史查看工具运行信息,如下图所示,数据集要素数量于属性表中打开的相同,都为32条。![]()
接下来使用ArcPy运行同一工具。点击分析选项卡Analysis,选择地理处理中Python下拉菜单,点击打开Python窗口。![]()
Python运行窗口初始时为空白,具有两个区域,第一个区域为脚本显示区,第二个区域为代码书写区。![]()
之后输入以下代码,按Enter键运行代码获取要素数量,可以看到输出结果与前文保持一致,也为32条记录。
arcpy.management.GetCount("population")
![]()
Python 窗口的顶部被称为脚本,底部被称为提示符。 脚本最初为空白的。 脚本提供先前输入的代码记录及其结果。提示符是您输入代码的地方。 当 Python 窗口第一次打开时,提示符中的消息显示为正在初始化 Python 解释程序,这表示该窗口正在准备接收代码。 几秒钟后,消息将被替换为在此处输入 Python 代码,这表示您可以开始输入代码。 首次打开 Python 窗口后,这些消息不会在当前会话中再次显示。
ArcPy 是 Python 包,使得 ArcGIS Pro 大多数功能可通过 Python 使用。GetCount() 是 ArcPy 的函数,可运行数据管理工具工具箱中的获取计数地理处理工具。
在 Python 窗口中运行代码行,将产生与使用工具窗格运行工具时所得结果相同的结果。 Python 窗口中运行的代码,同时也在历史记录窗格中创建一个新条目。![]()
在脚本上右键,点击Clear Transcript可以清除代码。![]()
5. 在Python窗口运行代码
**Python**窗口是练习编写 Python 代码的合适位置。在Python窗口中输入以下代码:
print("GIS is cool!")
![]()
继续练习,定义两个变量x,y,输出它们的乘积。
x=3
y=6
result = x * y
print(result) # 18
![]()
6. 获取代码帮助
通过将鼠标光标置于输入提示符出,可以显示代码帮助信息。
或者显示函数语法和描述信息,并具有代码自动补全功能。![]()
但很遗憾,我在本地完全没有使用过此功能,所以还有待验证。
7. 运行 Python 代码的方法比较
ArcPy脚本代码既可以在ArcGIS Pro的Python窗口中运行,也可以在Python编辑器中运行。下面是两种运行环境的差异比较。![]()
对于在 ArcGIS Pro 中运行的代码(包括 Python 窗口),使用时不需要导入 arcpy。对于在 ArcGIS Pro 外部运行的代码(例如在 Python 编辑器中),使用时必须先导入 arcpy,然后才能使用 ArcPy 包的功能。
8. 参考资料
https://learn.arcgis.com/zh-cn/projects/get-started-with-python-in-arcgis-pro
![]()
❝
GIS之路-开发示例数据下载,请在公众号后台回复:vector
全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试
❝
GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。
都看到这了,不要忘记点赞、收藏 + 关注 哦 !
本号不定时更新有关 GIS开发 相关内容,欢迎关注 ![]()
![]()
![]()
巴菲特继任者格雷格·阿贝尔发布第一封致伯克希尔哈撒韦股东信
中国学者领衔提出规范使用抗菌药物的数字化方案
DDR4价格已连续11个月上涨
理想汽车:2月交付新车26421辆
零跑汽车:2月份零跑全系交付28067台
泰国黄金交易限制措施生效,当局力图遏制泰铢升值
Vue3和Uniapp的爱恨情仇:小白也能懂的跨端秘籍
Vue3 与 UniApp 开发经验分享:跨端开发的选择与实践
最近不少刚接触前端的朋友问我,Vue3 和 UniApp 是不是竞争对手?
其实完全不是,我自己两个都在项目里用过,今天就从实际开发角度聊聊它们的区别、踩过的坑,以及怎么选。
先明确两者的定位
简单说:
- Vue3 是一个纯 Web 前端框架,主要用来写浏览器里跑的 H5 页面、Web 应用等。
- UniApp 是基于 Vue3 封装的跨端框架,它用 Vue3 的语法,但能把同一套代码编译到 H5、微信小程序、支付宝小程序、App、鸿蒙等多个平台。
举个实际例子:
如果你用 Vue3 写微信小程序,得额外用 Taro 这类框架做适配; 但用 UniApp 写,代码写完直接选平台打包就行,这是最直观的区别。
核心差别一:构建工具不一样
Vue3 的构建流程
Vue3 默认用 Vite 或 Webpack,我一般用 Vite,创建项目很简单:
# 创建 Vue3 项目
npm create vite@latest my-vue-app -- --template vue
cd my-vue-app
npm install
npm run dev
但如果你想把 Vue3 项目打包成 App,得额外加 Capacitor 或 Cordova,步骤会多一些:
# 1. 先打包成 H5
npm run build
# 2. 引入 Capacitor
npm install @capacitor/core @capacitor/cli
npx cap init my-app com.example.myapp
# 3. 添加 Android 平台
npm install @capacitor/android
npx cap add android
# 4. 同步代码并编译
npx cap sync
npx cap open android # 打开 Android Studio 编译安装包
UniApp 的构建流程
UniApp 官方推荐用 HBuilderX,也支持 CLI 方式。我用 HBuilderX 比较多,打包流程很直接:
- 在
HBuilderX里打开项目,点击顶部“发行”; - 选你要打包的平台(比如“微信小程序”“App-云打包”);
- 填一下基本信息(比如 App 名称、证书),点“打包”就行。
如果用 CLI 方式,创建和运行也很简单:
# 创建 UniApp 项目
npx degit dcloudio/uni-preset-vue#vite my-uniapp
cd my-uniapp
npm install
npm run dev:h5 # 运行 H5
npm run dev:mp-weixin # 运行微信小程序
核心差别二:API 不一样
Vue3 用的是 Web API
Vue3 里发请求、操作页面元素,用的都是浏览器原生 API 或第三方库,比如 axios:
// Vue3 里发请求(仅 H5 可用)
import axios from 'axios'
async function getUserInfo() {
try {
// 还要处理跨域问题,比如在 vite.config.js 里配代理
const res = await axios.get('https://api.example.com/user/info')
console.log(res.data)
} catch (err) {
console.error(err)
}
}
但这些代码放到小程序里会报错,因为小程序没有 axios,也没有 document 对象。
UniApp 用的是 uni.* API
UniApp 封装了一套跨端 API ,不管在哪个平台都能用,比如发请求:
// UniApp 里发请求(全平台通用)
async function getUserInfo() {
try {
const res = await uni.request({
url: 'https://api.example.com/user/info',
method: 'GET'
})
console.log(res.data)
} catch (err) {
console.error(err)
}
}
再比如获取用户信息,Vue3 里可能要调浏览器的 navigator,但 UniApp 直接用:
// UniApp 获取用户信息(以微信小程序为例)
uni.getUserProfile({
desc: '用于完善用户资料',
success: (res) => {
console.log(res.userInfo)
}
})
而且 UniApp 的 API 报错信息比较明确,调试起来比 Vue3 适配多端时省心。
核心差别三:页面路由写法不一样
Vue3 用 Vue Router
Vue3 的路由需要自己配置,先安装 vue-router:
npm install vue-router@4
然后在 src/router/index.js 里写配置:
// Vue3 路由配置
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Cart from '../views/Cart.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/cart',
name: 'Cart',
component: Cart
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
最后在 main.js 里挂载:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
UniApp 用 pages.json
UniApp 不需要自己装路由插件,直接在 pages.json 里配置就行:
// UniApp pages.json 配置
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationBarBackgroundColor": "#ff0000"
}
},
{
"path": "pages/cart/cart",
"style": {
"navigationBarTitleText": "购物车",
"navigationStyle": "default"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white"
}
}
页面跳转也很简单,直接用 uni.navigateTo:
// UniApp 页面跳转
uni.navigateTo({
url: '/pages/cart/cart'
})
另外,UniApp 支持三种页面文件:
-
.vue:通用文件,全平台能用; -
.nvue:原生渲染文件,App 端性能更好; -
.uvue:鸿蒙专用文件,编译后接近原生性能。
核心差别四:生态不一样
Vue3 的生态
Vue3 用 npm 包,生态非常丰富,比如做 3D 可以用 Three.js,做工具函数可以用 VueUse:
// Vue3 里用 Three.js
import * as THREE from 'three'
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)
document.body.appendChild(renderer.domElement)
但这些包很多是为 Web 端设计的,放到小程序或 App 中可能用不了。
UniApp 的生态
UniApp 有自己的插件市场,里面的插件都是跨端适配好的,比如支付可以用 uni-pay,地图可以用 uni-map:
<template>
<view>
<uni-map :latitude="39.908823" :longitude="116.397470" :scale="14"></uni-map>
</view>
</template>
不过插件市场的数量肯定不如 npm 多,一些特别小众的功能可能找不到现成的插件。
什么时候选 UniApp?什么时候选 Vue3?
选 UniApp 的场景
我之前帮一个创业团队做过项目,他们需要同时做微信小程序、App 和 H5,预算有限,开发周期也紧。用 Vue3 的话得分别开发三端,至少要 2-3 个开发;用 UniApp 一个人就能搞定,代码写完直接打包,开发周期缩短了一半。
另外,如果项目需要高频迭代,比如外卖小程序,今天改满减活动,明天改商品列表,UniApp 改一次代码所有平台同步,测试一次就行,效率很高。
还有对 App 性能有要求的场景,用 UniApp 的 .nvue 或 .uvue 文件,能调用原生组件,滑动长列表比纯 Vue3 写的 H5 套壳 App 流畅很多。
选 Vue3 的场景
如果只做 Web 端,比如企业官网、后台管理系统,选 Vue3 更合适。UniApp 为了跨端会有一些额外的代码开销,而且 Vue3 可以随便用 npm 上的 Web 插件,比如做复杂的 3D 交互、数据可视化,Vue3 比 UniApp 灵活很多。
还有做图形密集型应用,比如手机游戏,UniApp 的性能跟不上,得用 Vue3 配合专业的游戏引擎。
最后总结
根据我的经验:
- 要做小程序、App、H5 多端,选 UniApp;
- 只做 Web 端,或者需要复杂的 Web 交互,选 Vue3。
而且先学 Vue3 再学 UniApp 很快,因为语法基本一样,就是多了 uni.* API 和 pages.json 配置。
两者不是竞争对手,而是可以搭配用的:用 Vue3 打好前端基础,用 UniApp 拓展跨端场景,这样开发起来更顺手。
如果你有具体的项目场景,也可以留言,我可以帮你分析一下用哪个更合适。
如何用 WebGL 去实现一个选取色彩背景图片透明化小工具 - Pick Alpha
原文链接:老船长PZ_Jack の 博客
先简要介绍一下 WebGL
博主也是 WebGL 的新手,以下内容主要是基于我最近学习 WebGL 的一些笔记和总结,可能会有些零散和不系统,但希望能对同样想入门 WebGL 的朋友有所帮助。
当今大多数主流浏览器存在2个 WebGL 版本: WebGL 1.0(基于 OpenGL ES2.0)、WebGL 2.0(基于 OpenGL ES3.0)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl')
const gl2 = canvas.getContext('webgl2')
在 WebGL 中,所有的绘制操作都是通过着色器(Shader)来完成的。着色器是一段运行在 GPU 上的小程序,并且为一对函数的形式组成:
-
顶点着色器(Vertex Shader):负责处理每个顶点的数据,进行变换和投影等操作。 -
片段着色器(Fragment Shader):负责处理每个片段(像素)的数据,进行颜色计算和纹理采样等操作。
其组合又称为程序(Program),而程序中的着色器会在 GPU 上对每个顶点/片元并行执行,这也是 WebGL 能够实现高性能图形渲染的原因之一。你可以想象如果是 CPU 处理,它只有少量核心,并且在Web场景上以单核心面对数百万个像素逐个计算,而 GPU 拥有成百上千个核心,可以同时处理大量像素,效率自然天差地别。
注意一点哦~ 是同时,即你写的着色器代码是同时针对单个顶点/片段的,GPU 会同时执行成千上万个这样的着色器实例来处理所有的顶点和片段,这就是并行计算的魅力所在。
在 WebGL 中,顶点着色器和片段着色器都是用 GLSL(OpenGL Shading Language)编写的。GLSL 是一种专门为图形编程设计的语言,具有类似于 C 语言的语法结构。我这边以WebGL 2.0为例,来展示一下最基本的着色器代码示例:
顶点着色器(Vertex Shader)示例:
#version 300 es
// 👆这里的 #version 300 es 是告诉编译器我们使用的是 WebGL 2.0 的 GLSL 版本
// 并且必须放在着色器代码的第一行,否则将默认设置为 GLSL ES 1.00(即 WebGL 1.0 的语法)
// 声明输入变量 a_position,类型为 vec4(4维向量),表示顶点的位置
in vec4 a_position;
void main() {
// 将输入的顶点位置直接赋值给内置变量 gl_Position,表示最终的顶点位置
gl_Position = a_position;
}
片段着色器(Fragment Shader)示例:
#version 300 es
// 声明浮点数的精度为 highp(高精度),这是 WebGL 2.0 中的一个要求,确保在片段着色器中使用高精度的浮点数
precision highp float;
// 声明输出变量 outColor,类型为 vec4(4维向量),表示片段的颜色
// 如果你使用的是 WebGL 1.0,那么片段着色器中应该使用内置变量 gl_FragColor 来输出颜色,而不是自定义的输出变量
// 在 WebGL 2.0 中,我们可以使用 out 关键字来声明一个输出变量,这样就不需要使用 gl_FragColor 了
// 并且在 WebGL 2.0 中,第一个声明的输出变量默认绑定到颜色缓冲区 0(即 gl_FragColor),所以我们可以直接使用 outColor 来输出颜色
out vec4 outColor;
// 如果有多个输出变量,我们可以使用 layout(location = X) 来指定它们在颜色缓冲区中的位置,这样就可以同时输出到多个缓冲区了
// 在 GLSL 中手动指定位置
// layout(location = 0) out vec4 outColor; 输出到颜色缓冲
// layout(location = 1) out vec4 outNormal; 输出到另一个缓冲
void main() {
// 将输出颜色设置为红色(RGBA: 1.0, 0.0, 0.0, 1.0)
outColor = vec4(1.0, 0.0, 0.0, 1.0);
}
简要介绍一下核心概念:
-
属性(Attribute)、缓冲区(Buffer)、顶点数组对象(Vertex Array Object,VAO):
属性(Attribute):这是顶点着色器中的输入变量,用于接收每个顶点的数据,例如位置、颜色、纹理坐标等。属性变量在 JavaScript 中通过缓冲区对象(Buffer Object)传递给 GPU。
// 这是一个属性变量,表示顶点的位置
// 命名以 a_ 开头是一个常见的约定,表示这是一个属性变量
in vec4 a_position;
缓冲区(Buffer):这是 WebGL 中用于存储数据的对象,可以存储顶点数据、索引数据等。缓冲区在 JavaScript 中通过 WebGL API 创建和使用。
顶点数组对象(Vertex Array Object,VAO):这是 WebGL 2.0 中引入的一个对象,用于管理属性变量和缓冲区之间的关系。简单来说,顶点数组对象相当于是一个状态快照,将属性变量的配置和绑定信息保存起来,这样在绘制时只需要绑定顶点数组对象即可恢复之前的配置,避免了重复设置属性变量和缓冲区的麻烦。同时需要注意当你使用完毕后要解绑顶点数组对象(VAO),以免记录到不需要的后续渲染操作。
-
统一变量(Uniform):这是顶点着色器和片段着色器中的输入变量,用于接收全局数据,例如变换矩阵、光照参数等。统一变量在 JavaScript 中通过程序对象(Program Object)传递给 GPU。
// 这是一个统一变量,表示纹理采样器,用于在片段着色器中访问纹理数据
// 命名以 u_ 开头是一个常见的约定,表示这是一个统一变量
uniform sampler2D u_texture;
-
纹理(Texture):这是片段着色器中的输入变量,用于接收纹理数据,例如图片、视频等。纹理在 JavaScript 中通过纹理对象(Texture Object)传递给 GPU。(比如图片就是一种纹理)
// 通常 纹理的采样器会被声明为一个统一变量,类型为 sampler2D,表示这是一个二维纹理
uniform sampler2D u_texture;
-
内置变量(Built-in Variable):这是 GLSL 中预定义的变量,用于表示一些特殊的数据,例如 gl_Position(顶点位置)、gl_FragColor(片段颜色)等。这些变量在着色器中具有特殊的意义和用途。
// 这是一个内置变量,表示最终的顶点位置,必须在顶点着色器中赋值
gl_Position = a_position;
-
程序对象(Program Object):这是 WebGL 中用于管理着色器程序的对象,包含了顶点着色器和片段着色器的组合。程序对象在 JavaScript 中通过 WebGL API 创建和使用。 -
插值变量(Varying):这是顶点着色器和片段着色器之间的变量,用于在两者之间传递数据,例如颜色、纹理坐标等。插值变量在 JavaScript 中通过程序对象传递给 GPU。(比如常见的渐变色就是通过插值变量来实现的)
注意:在 WebGL 2.0 中,插值变量的显示声明已经被废弃,取而代之的是使用 out 关键字在顶点着色器中声明输出变量,并在片段着色器中使用 in 关键字声明输入变量来接收这些数据。
实现一个图片背景透明化小工具
GLSL 核心转换逻辑代码:
核心算法是:将 RGB 颜色空间转换为 YUV 颜色空间,在排除掉亮度(Y)分量的影响后,计算色度(UV)分量之间的距离来判断颜色是否接近于选取的颜色,从而实现选取色彩透明化的效果。
![]()
- 顶点着色器(Vertex Shader):
#version 300 es
// 顶点位置属性变量,表示每个顶点的位置
in vec2 a_position;
// 纹理坐标属性变量,表示每个顶点对应的纹理坐标
in vec2 a_texCoord;
// varying 变量,用于在顶点着色器和片段着色器之间传递纹理坐标数据
// 使用线性插值变量,GPU 会自动在顶点之间进行插值计算,使得片段着色器能够获得每个片段对应的纹理坐标
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
- 片段着色器(Fragment Shader):
#version 300 es
precision highp float;
// 从顶点着色器传递过来的纹理坐标变量
in vec2 v_texCoord;
// 统一变量:图片纹理
uniform sampler2D u_image;
// 统一变量:选取的颜色
uniform vec3 u_pickColor;
// 统一变量:容差值,用于控制颜色匹配的范围
uniform float u_tolerance;
out vec4 outColor;
// RGB 转 YCbCr 的转换矩阵
const mat3 rgb2ycbcr = mat3(
0.299, -0.169, 0.500,
0.587, -0.331, -0.419,
0.114, 0.500, -0.081
);
void main() {
vec4 color = texture(u_image, v_texCoord);
vec3 rgb = color.rgb;
// 将当前像素的 RGB 颜色转换为 YCbCr 颜色空间
vec3 pickYcbcr = rgb2ycbcr * u_pickColor;
// 计算当前像素的 RGB 颜色与选取颜色在 YCbCr 颜色空间中的距离
float dist = distance(rgb2ycbcr * rgb, pickYcbcr);
// 使用 smoothstep 实现柔和的边缘过渡,根据距离和容差值计算出 alpha 值,使得颜色接近选取颜色的像素变得透明,而其他像素保持不变
float edge = u_tolerance * 0.15;
float alpha = smoothstep(u_tolerance - edge, u_tolerance + edge, dist);
outColor = vec4(rgb, alpha);
}
准备好 WebGL在JS的基础工具函数集
首先第一个是编译着色器(Compile Shader),它将 GLSL 代码编译成 GPU 可执行的着色器对象。
export function compileShader({
gl,
type,
source,
}: {
gl: WebGL2RenderingContext
type: number
source: string
}) {
const shader = gl.createShader(type)!
gl.shaderSource(shader, source)
gl.compileShader(shader)
// 检查着色器编译是否成功,如果失败则获取错误信息并抛出异常
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader)
gl.deleteShader(shader)
throw new Error(`Shader compile error: ${info}`)
}
return shader
}
第二个是创建程序对象(Program Object)
它将顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)编译并链接成一个可用的程序对象,供后续的渲染使用。
// 创建程序的工具函数,接受 WebGL 上下文、顶点着色器源代码和片段着色器源代码作为参数,返回链接后的程序对象
export function createProgram({
gl,
vertexShader,
fragmentShader,
}: {
gl: WebGL2RenderingContext
vertexShader: string
fragmentShader: string
}) {
const vs = compileShader({ gl, type: gl.VERTEX_SHADER, source: vertexShader })
const fs = compileShader({ gl, type: gl.FRAGMENT_SHADER, source: fragmentShader })
const program = gl.createProgram()!
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
// 检查程序链接是否成功,如果失败则获取错误信息并抛出异常
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program)
gl.deleteProgram(program)
throw new Error(`Program link error: ${info}`)
}
return program
}
第三个是创建纹理对象(Texture Object),它用于在 GPU 上存储图像数据,以便在着色器中进行采样和渲染。
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true) 这一行代码的作用是告诉 WebGL 在上传纹理数据时自动将图片进行垂直翻转,以适应 WebGL 的坐标系统。因为在 WebGL 中,纹理坐标的原点位于左下角,而在 HTML 中,图片的原点位于左上角,所以需要进行垂直翻转来确保纹理正确显示。
直接来看下面这张图👇
我们可以看到在 WebGL 中,纹理坐标的原点(0, 0)位于左下角,而在 HTML 中,图片的原点(0, 0)位于左上角。因此,在实际接收到由 HTML 图片元素上传的纹理数据时,WebGL 会默认将其视为从左下角开始的纹理坐标系统,这就导致了图片在 WebGL 中显示时会被颠倒过来。
export function createTexture({
gl,
image,
}: {
gl: WebGL2RenderingContext
image: HTMLImageElement
}) {
const texture = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, texture)
// 设置 UNPACK_FLIP_Y_WEBGL 参数为 true,可以在上传纹理数据时自动将图片进行垂直翻转,以适应 WebGL 的坐标系统
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
// 将图片数据上传到 GPU 上的纹理对象中,指定纹理的格式和数据类型
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
// 下面这些操作主要是为了优化处理一些缩放和图片边缘问题。
// 生成 mipmap、设置纹理的包装模式和过滤模式等,以确保纹理在不同缩放级别下能够正确显示
gl.generateMipmap(gl.TEXTURE_2D)
// 这2行代码设置了纹理的包装模式为 CLAMP_TO_EDGE,表示纹理坐标超出 [0, 1] 范围时会被夹紧到边缘像素的颜色,而不是重复或镜像纹理。
// 这对于我们这种需要对图片进行透明化处理的场景来说是非常重要的,因为我们不希望在边缘出现不必要的重复或镜像效果。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
// 这2行代码设置了纹理的过滤模式,分别是缩小过滤(TEXTURE_MIN_FILTER)和放大过滤(TEXTURE_MAG_FILTER)。
// 对于缩小过滤,我们使用了 LINEAR_MIPMAP_LINEAR,这是一种三线性过滤模式,可以在不同 mipmap 级别之间进行平滑过渡,从而获得更好的视觉效果。
// 对于放大过滤,我们使用了 LINEAR,这是一种双线性过滤模式,可以在放大纹理时进行平滑插值,避免出现锯齿状的边缘。
// 这些设置可以确保我们的纹理在不同缩放级别下都能够正确显示,并且在进行透明化处理时不会出现明显的锯齿或模糊现象。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
return texture
}
第四个是创建顶点数组对象(Vertex Array Object,VAO),它用于管理属性变量和缓冲区之间的关系,简化了渲染时的状态设置。
export function createFullRenderAreaVAO({
gl,
program,
}: {
gl: WebGL2RenderingContext
program: WebGLProgram
}) {
const vao = gl.createVertexArray()!
gl.bindVertexArray(vao)
// 定义一个包含顶点位置和纹理坐标的缓冲区数据,表示一个覆盖整个渲染区域的矩形(由两个三角形组成)
// 每个顶点包含4个浮点数,前两个表示位置(x, y),后两个表示纹理坐标(u, v)
// (x, y) 的范围是 [-1, 1],表示覆盖整个裁剪空间,而(u, v)的范围是 [0, 1],表示覆盖整个纹理空间
const bufferData = new Float32Array([
-1, -1, 0, 0,
1, -1, 1, 0,
-1, 1, 0, 1,
1, 1, 1, 1
])
const buffer = gl.createBuffer()!
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, bufferData, gl.STATIC_DRAW)
// 步长(stride)表示每个顶点数据的字节长度,这里是4个浮点数,每个浮点数占用4字节,所以总共是16字节
const stride = 4 * Float32Array.BYTES_PER_ELEMENT
// 获取顶点着色器中属性变量 a_position 的位置,并启用该属性数组,然后设置属性指针,告诉 WebGL 如何从缓冲区中读取数据来填充 a_position 变量
const posLoc = gl.getAttribLocation(program, 'a_position')
gl.enableVertexAttribArray(posLoc)
// 参数说明:位置属性的索引、每个顶点数据包含的组件数量(2表示x和y)、数据类型(FLOAT表示32位浮点数)、是否归一化(false表示不进行归一化)、步长(每个顶点数据的字节长度)、偏移量(0表示从缓冲区的起始位置开始读取)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)
// 同理,并且这边的偏移量是2 * Float32Array.BYTES_PER_ELEMENT,表示纹理坐标数据在每个顶点数据中的位置(前两个浮点数是位置,后两个浮点数是纹理坐标,所以偏移量是2个浮点数的字节长度)
const texLoc = gl.getAttribLocation(program, 'a_texCoord')
gl.enableVertexAttribArray(texLoc)
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, stride, 2 * Float32Array.BYTES_PER_ELEMENT)
// 最后解绑顶点数组对象(VAO),以免记录到不需要的后续渲染操作
gl.bindVertexArray(null)
return vao
}
利用工具函数集来实现Pick Alpha 的 逻辑层hook
这里使用了Vue3,你也可以根据自己的技术栈来调整实现方式,核心逻辑是一样的。 这个hook主要包含了以下功能:
- 初始化 WebGL 上下文和着色器程序
- 加载图片并创建纹理
- 处理画布点击事件,获取选取的颜色并更新渲染
- 根据选取的颜色和容差值重新渲染画布,实现透明化
import fs from '../shaders/fs.glsl?raw'
import vs from '../shaders/vs.glsl?raw'
import { createFullRenderAreaVAO, createProgram, createTexture } from '../utils/webgl'
export const usePickAlpha = ({ canvas }: { canvas: Ref<HTMLCanvasElement | null> }) => {
const pickColor = ref<[number, number, number]>([0, 0, 0])
const tolerance = ref(0)
const hasImage = ref(false)
let gl: WebGL2RenderingContext | undefined | null
let program: WebGLProgram | undefined
let vao: WebGLVertexArrayObject | undefined
let imageData: ImageData | undefined
let texture: WebGLTexture | undefined
// 初始化 WebGL 上下文和着色器程序,创建顶点数组对象(VAO)来管理属性变量和缓冲区之间的关系
const initWebGL = () => {
gl = canvas.value?.getContext('webgl2', {
premultipliedAlpha: false,
preserveDrawingBuffer: true,
})
if (!gl) {
console.error('WebGL2 is not supported in this browser.')
return null
}
program = createProgram({ gl, vertexShader: vs, fragmentShader: fs })
vao = createFullRenderAreaVAO({ gl, program })
}
// 渲染函数,根据当前的选取颜色和容差值重新渲染画布,实现透明化效果
const render = () => {
if (!gl || !program || !vao)
return
// 设置视口和清除画布
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
// 挂载程序(Program)和顶点数组对象(VAO)
gl.useProgram(program)
gl.bindVertexArray(vao)
// 设置统一变量(Uniforms)
const pickColorLoc = gl.getUniformLocation(program, 'u_pickColor')
const toleranceLoc = gl.getUniformLocation(program, 'u_tolerance')
gl.uniform3fv(pickColorLoc, pickColor.value)
gl.uniform1f(toleranceLoc, tolerance.value)
// 绘制
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.bindVertexArray(null)
}
// 加载图片并创建纹理,同时获取图片的像素数据以便后续的颜色选取操作
// 这里复制图片数据到一个临时的 canvas 上,以便我们能够通过 getImageData 获取到像素数据进行颜色选取的操作。
const loadImage = (image: HTMLImageElement) => {
if (!gl || !program || !vao || !canvas.value) {
console.log('gl', gl, 'program', program, 'vao', vao, 'canvas', canvas.value)
console.error('WebGL is not initialized.')
return
}
canvas.value.width = image.naturalWidth
canvas.value.height = image.naturalHeight
const tmp = document.createElement('canvas')
tmp.width = image.naturalWidth
tmp.height = image.naturalHeight
const ctx = tmp.getContext('2d')!
ctx.drawImage(image, 0, 0)
imageData = ctx.getImageData(0, 0, tmp.width, tmp.height)
if (texture) {
gl.deleteTexture(texture)
}
texture = createTexture({ gl, image })
hasImage.value = true
render()
}
// 处理画布点击事件,获取选取的颜色并更新渲染
const handleCanvasClick = (event: MouseEvent) => {
if (!canvas.value || !imageData)
return
const rect = canvas.value.getBoundingClientRect()
const x = Math.floor((event.clientX - rect.left) / rect.width * canvas.value.width)
const y = Math.floor((event.clientY - rect.top) / rect.height * canvas.value.height)
const colorIdx = (y * imageData.width + x) * 4
// 归一化 RGB 颜色值到 [0, 1] 范围,并更新 pickColor 的值
pickColor.value = [
imageData.data[colorIdx] / 255,
imageData.data[colorIdx + 1] / 255,
imageData.data[colorIdx + 2] / 255,
]
console.log('Picked color:', pickColor.value)
render()
}
onMounted(() => initWebGL())
watch([pickColor, tolerance], () => render())
return {
pickColor,
tolerance,
hasImage,
loadImage,
handleCanvasClick,
render,
}
}
最后是View层的实现,这里就不赘述了,核心逻辑都在上面的hook里面了,你可以根据自己的技术栈来调整实现方式。
最后供上 Pick Alpha 的完整版在线地址
- Pick Alpha: pick-alpha.panzer-jack.cn/
参考链接
简单高效的状态管理方案:Hox + ahooks
在 React 开发中,状态管理是一个绕不开的话题。随着应用规模的增长,组件之间的数据传递和状态同步会变得愈发复杂。如何选择一款合适的状态管理方案,直接影响着开发体验和代码可维护性。本文将带你梳理当前主流的状态管理方案,并重点推荐一套简洁高效的组合:Hox + ahooks。
为什么需要状态管理
随着应用规模扩大,我们需要在不同组件之间共享数据。想象一下:用户在某个页面修改了头像,这个变化需要实时反映在导航栏、侧边栏等多个位置。如果没有统一的状态管理,就只能通过层层传递 props,代码冗余且难以维护。
状态管理有三个核心目标:统一管理——所有共享数据存储在可预测的位置;响应式更新——状态变化时依赖它的组件自动重渲染;可预测性——状态变化可控、可追踪。
React 中需要管理的状态分为三类:本地状态——组件内部 useState 管理,通常不跨组件共享;服务端状态——从 API 获取的数据,需要缓存、轮询等能力;全局状态——多组件共享的状态,如用户信息、主题、购物车等。
主流方案概览
React 生态中的状态管理方案百花齐放,各有各的设计哲学和适用场景。为了帮助大家建立一个整体认知,我们先来看一下主流方案的对比。
| 方案 | 学习成本 | 包体积 | 社区活跃度 | 适用场景 |
|---|---|---|---|---|
| Redux Toolkit | 中高 | 约12KB | 非常高 | 大型企业级项目 |
| Zustand | 低 | 约1KB | 高 | 中小型项目 |
| Hox | 低 | 约2KB | 低 | 追求极简体验 |
| useState + Context | 低 | 0 | 内置 | 简单场景 |
从 npm 下载量来看,Redux 仍然占据主导地位,但 Zustand 的增长速度非常惊人,在 2024 年的 State of React 调研中,Zustand 的满意度和使用率都位居前列。值得注意的是,传统的 useState 配合 Context 仍然是很多开发者的首选,这说明在很多场景下,我们其实不需要引入额外的状态管理库。
Redux Toolkit
Redux 是 React 状态管理领域的鼻祖,核心理念是单向数据流和不可变状态。但原始 Redux 过度设计:繁琐的 action types、冗长的 reducers、重复的模板代码,让很多开发者望而却步。
Redux Toolkit 是官方推荐的新一代工具集,通过简化的 API 大大降低了门槛。核心包括:createSlice——在一个文件中定义 state、reducer 和 action;createAsyncThunk——处理异步逻辑;configureStore——简化 store 创建。
import { createSlice, configureStore } from '@reduxjs/toolkit'
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
},
})
const store = configureStore({ reducer: counterSlice.reducer })
export const { increment, decrement } = counterSlice.actions
组件中使用 useSelector 和 useDispatch:
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement } from './store'
function Counter() {
const count = useSelector((state) => state.counter.value)
const dispatch = useDispatch()
return (
<div>
<span>{count}</span>
<button onClick={() => dispatch(increment())}>+</button>
<button onClick={() => dispatch(decrement())}>-</button>
</div>
)
}
Redux Toolkit 适合对状态管理有严格要求的大型项目,需要时间旅行调试、复杂中间件等高级特性。但如果项目规模不大,引入 Redux 可能带来不必要的复杂度。
Zustand + tanstack-query
Zustand 是一个轻量级的状态管理库,其名字在德语中意为“状态”。Zustand 的设计理念是极简主义,它没有 Redux 那么多约束性的概念,只需要几行代码就能创建一个可全局共享的状态。Zustand 使用 Hooks API 来创建和消费状态,这使得它与 React 的开发模式完美契合。
Zustand 的核心优势在于它的简洁性。创建一个 store 只需要调用 create 函数,传入一个返回状态和方法的函数即可。与 Redux 不同的是,Zustand 不需要 Provider 包裹,组件可以直接通过 Hook 来消费状态。这种设计大大减少了组件树的复杂度和不必要的重新渲染。
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}))
function Counter() {
const { count, increment, decrement } = useCounterStore()
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
)
}
而 tanstack-query(原 React Query)则是专门用于管理服务端状态的神器。它解决了数据获取、缓存、同步、轮询等一系列常见的后端数据管理需求。tanstack-query 的核心理念是将服务端数据视为一种特殊的“状态”,它应该独立于 UI 状态来管理。通过内置的缓存机制、后台刷新、乐观更新等功能,tanstack-query 大大简化了前后端数据交互的复杂度。
Zustand + tanstack-query 的组合在 2024 年备受推崇,这种组合兼顾了全局状态管理和服务端数据管理的需求,且两者都保持了极简的 API 设计。对于中型项目来说,这是一个性价比极高的选择。
其他方案
mobx、valtio、jotai,略。
为什么推荐 Hox + ahooks
在众多状态管理方案中,我想特别推荐 Hox + ahooks 这个组合。Hox 是一个专注于状态共享的轻量级库,而 ahooks 则是阿里巴巴开源的高质量 React Hooks 库。两者结合,能够提供一种极其简洁、直观的状态管理体验。
Hox 的核心理念是“状态即模型,模型即 Hook”。这意味着你可以用编写普通 React Hook 的方式来编写状态模型,不需要学习任何新的概念。在 Hox 中,创建一个全局状态与创建一个本地状态几乎没有区别,这大大降低了状态管理的复杂度。当你需要将一个组件的本地状态改为全局共享状态时,只需要将 useState 替换成 Hox 提供的 createGlobalStore 函数即可。
这种设计的优势是显而易见的。首先,它几乎不需要额外的学习成本,熟悉 React Hooks 的开发者可以立即上手;其次,它支持 TypeScript 类型自动推断,无需手动声明复杂类型;最后,它的 API 设计与 React 思维高度一致,不会产生心智负担。
ahooks 则是 React Hooks 工具库的佼佼者,它提供了大量实用的 Hook,覆盖了状态管理、DOM 操作、网络请求、传感器等众多场景。ahooks 的特点是高质量、可靠性强,由阿里巴巴前端团队维护,已在大量生产项目中得到验证。特别值得一提的是,ahooks 完美支持 SSR,这对于需要支持服务端渲染的项目来说是重要优势。
Hox + ahooks 的组合完美互补:Hox 解决全局状态共享问题,ahooks 则提供了丰富的工具 Hook 来处理各类复杂场景。从本质上讲,ahooks 解决的是“怎么做”的问题,而 Hox 解决的是“在哪存”的问题,两者配合使用,能够覆盖绝大多数前端状态管理需求。
Hox 核心用法
了解了 Hox 的设计理念,接下来我们深入探讨它的具体用法。Hox 的 API 设计非常简洁,只有两个核心函数:createGlobalStore 用于创建状态模型,useModel 用于在组件中消费状态。
安装
npm install hox
# 或
yarn add hox
# 或
pnpm add hox
创建状态模型
使用 Hox 创建一个全局状态模型非常简单,只需要调用 createGlobalStore 函数并传入一个返回状态和方法的函数即可。这个函数的写法与普通的自定义 Hook 完全一致,你可以在其中使用 useState、useReducer、useEffect 等任何 React Hooks。
// models/useCounter.js
import { createGlobalStore } from 'hox'
import { useState } from 'react'
export default createGlobalStore(function useCounter() {
const [count, setCount] = useState(0)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(0)
return {
count,
increment,
decrement,
reset
}
})
这段代码看起来与普通的自定义 Hook 几乎一模一样,唯一的区别是使用了 createGlobalStore 函数进行包裹。createGlobalStore 函数会确保这个 Hook 返回的状态和方法能够在多个组件之间共享。
统一挂载全局状态
hox 的 createGlobalStore 生成的状态需要以组件的形式挂载的 react 树上。用 HoxRoot 包起来即可:
import { HoxRoot } from 'hox'
ReactDOM.render(
<HoxRoot>
<App />
</HoxRoot>,
domContainer
)
在组件中使用
在组件中使用 Hox 创建的状态同样简单,只需要导入对应的模型并调用即可:
import useCounter from '../models/useCounter'
function Counter() {
const { count, increment, decrement, reset } = useCounter()
return (
<div>
<h2>计数: {count}</h2>
<button onClick={increment}>增加</button>
<button onClick={decrement}>减少</button>
<button onClick={reset}>重置</button>
</div>
)
}
可以看到,使用方式与普通 Hook 完全相同。Hox 会自动处理状态的共享和响应式更新,你不需要关心 Provider 的配置,也不需要担心状态泄漏到其他不相关的组件。
优化订阅
hox 的优化订阅是通过返回一个数组做浅比较,hook 本身还是全部返回的。
const { count } = useCounter(s => [s.count])
TypeScript 支持
Hox 对 TypeScript 提供了开箱即用的支持。当你使用 TypeScript 编写模型时,类型推断会自动完成,无需额外的类型声明:
// models/useCounter.ts
import { createGlobalStore } from 'hox'
import { useState } from 'react'
interface CounterState {
count: number
increment: () => void
decrement: () => void
reset: () => void
}
export default createGlobalStore<CounterState>(function useCounter() {
const [count, setCount] = useState(0)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
const reset = () => setCount(0)
return {
count,
increment,
decrement,
reset
}
})
不过,由于 Hox 采用了独特的类型推断机制,即使你不在 createGlobalStore 中显式声明类型,VS Code 等编辑器通常也能自动推断出正确的类型。
配合 ahooks 使用(可以是其他任意的 hook 库)
能够无痛复用各种 hooks 并将之于 store 整合在一起就是我认为 hox + ahooks 比 zustand + tanstack-query 要好的理由。 zustand 就没有这种类似的自由组合自定义 hook 的能力,相信我,在 zustand 里面实现请求的节流、防抖、轮询等逻辑就是噩梦,把请求放在 tanstack-query 这种不伦不类的用法真看不懂。
ahooks 是阿里巴巴开源的高质量 React Hooks 库,它提供了丰富的工具 Hook,能够与 Hox 形成完美的互补。ahooks 的特点是大而全、文档详细、质量可靠,已被大量国内外企业采用。
Hox 的一个重要优势是:你可以在全局状态模型中自由使用任何 React Hooks,包括 ahooks。这意味着你可以把 ahooks 的能力直接封装进全局状态,让状态管理模型更加强大。
useRequest:在 Hox 模型中管理网络请求
useRequest 是 ahooks 最核心的 Hook 之一,专门用于管理网络请求的状态。将 useRequest 融入 Hox 模型,可以轻松实现数据获取、轮询、缓存、防抖等功能:
// models/useUser.js
import { createGlobalStore } from 'hox'
import { useRequest } from 'ahooks'
export default createGlobalStore(function useUser() {
const { data, loading, error, run, refresh, mutate } = useRequest(
(userId) => fetch(`/api/users/${userId}`).then(res => res.json()),
{
manual: false,
defaultParams: [1],
}
)
const updateUser = async (userId, updates) => {
const originalData = data.value
mutate((currentData) => ({
...currentData,
...updates
}))
try {
await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
})
refresh()
} catch (err) {
mutate(() => originalData)
}
}
return {
user: data,
loading,
error,
refresh,
updateUser,
}
})
这样一来,使用该状态的组件只需要调用 useUser() 即可自动获得请求状态,无需在每个组件中重复编写请求逻辑。
useRequest 的配置项非常丰富:pollingInterval 可以设置轮询间隔,实现定时刷新;debounceInterval 可以将请求防抖处理,避免频繁请求;refreshOnWindowFocus 可以在窗口重新获得焦点时自动刷新数据;cacheKey 和 cacheTime 则提供了数据缓存能力。
useDebounce:在 Hox 模型中处理防抖
对于需要防抖或节流的场景,可以在 Hox 模型中直接使用 ahooks:
// models/useSearch.js
import { createGlobalStore } from 'hox'
import { useState, useEffect } from 'react'
import { useDebounce } from 'ahooks'
export default createGlobalStore(function useSearch() {
const [keyword, setKeyword] = useState('')
const [results, setResults] = useState([])
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
useEffect(() => {
if (!debouncedKeyword) {
setResults([])
return
}
fetch(`/api/search?q=${debouncedKeyword}`)
.then(res => res.json())
.then(data => setResults(data))
}, [debouncedKeyword])
return {
keyword,
setKeyword,
results,
}
})
useLocalStorageState:在 Hox 模型中持久化状态
如果需要将状态持久化到 localStorage,useLocalStorageState 提供了优雅的解决方案:
// models/useSettings.js
import { createGlobalStore } from 'hox'
import { useLocalStorageState } from 'ahooks'
export default createGlobalStore(function useSettings() {
const [theme, setTheme] = useLocalStorageState('app-theme', 'light')
const [language, setLanguage] = useLocalStorageState('app-language', 'zh-CN')
const toggleTheme = () => {
setTheme(t => t === 'light' ? 'dark' : 'light')
}
return {
theme,
language,
setTheme,
setLanguage,
toggleTheme,
}
})
这个 Hook 会自动处理序列化、反序列化,以及跨标签页同步等细节。组件中使用时:
import useSettings from '../models/useSettings'
function ThemeToggle() {
const { theme, toggleTheme } = useSettings()
return (
<button onClick={toggleTheme}>
当前主题: {theme}
</button>
)
}
这些 Hook 大多数都是独立的,可以直接与 Hox 或其他状态管理方案配合使用。ahooks 的设计理念是“即插即用”,你不需要为了使用某个 Hook 而引入整个库,可以按需导入。
适用场景
任何技术方案都有其适用范围,Hox + ahooks 也不例外。理解这些方案的适用场景,能够帮助我们做出更明智的技术决策。
Hox + ahooks 最适合以下场景:首先是中小型项目,这类项目通常不需要复杂的状态架构,但仍然需要状态共享能力;其次是追求开发效率的团队,Hox 的学习曲线几乎为零,开发者可以立即投入生产;第三是对代码简洁性有要求的项目,Hox + ahooks 的组合代码量极小,可读性好;第四是需要快速迭代的项目,由于 Hox 的零心智负担特性,重构和调整都变得轻而易举;最后是个人项目或初创项目,这类场景通常追求快速上线而非长期可维护性。
对于大型企业级项目,Redux Toolkit 仍然是更稳妥的选择。Redux 的严格约束在大型团队中能够发挥优势:统一的状态结构使得代码审查更容易,强大的调试工具能够快速定位问题,完善的中间件生态能够满足各类扩展需求。虽然 Redux 的学习曲线较陡,但一旦团队掌握,往往能够保持较高的一致性。
总结
状态管理是 React 开发中的核心议题,选择合适的方案对项目成功至关重要。本文详细介绍了当前主流的状态管理方案:Redux Toolkit 适合大型项目和对规范性有高要求的团队;Zustand + tanstack-query 则是中型项目的热门选择,兼顾了简洁性和功能性;而 Hox + ahooks 的组合,以其极简的设计理念和零学习成本,成为中小型项目的理想选择。
Hox 的核心优势可以概括为三点:首先是简单,它使用与普通 Hook 完全一致的 API,不需要额外的概念;其次是直观,状态管理逻辑与组件逻辑写在同样的位置,代码可读性极高;第三是灵活,它既支持简单的全局状态,也能处理复杂的异步逻辑和副作用。
在实际项目中,我建议采用渐进式的技术选型策略:从小处着手,先使用本地状态和 Context 来解决简单需求;当发现状态开始变得难以管理时,再引入 Hox 来抽象全局状态;遇到复杂的网络请求场景时,补充 ahooks 的 useRequest。这种方式能够避免过早引入复杂性,让项目保持轻盈的同时具备扩展能力。
最后,技术的选择永远应该服务于业务需求。没有最好的方案,只有最适合的方案。希望本文能够帮助你更好地理解 React 状态管理的生态,并在实际项目中做出明智的技术决策。
当「多应用共享组件」成了刚需:我们从需求到模块联邦的落地小史
当「多应用共享组件」成了刚需:我们从需求到模块联邦的落地小史
以真实项目需求为背景,讲我们如何从「NPM 发包、iframe 子应用」的坑里走出来,用 Webpack 5 的模块联邦实现多应用运行时共享组件,并给出落地要点与避坑小结。
一、需求从哪来:多产品线要共用一套「家当」
我们这边有一条业务线,同时维护着中台配置端、运营活动页、数据看板 等多个独立前端应用。这些应用技术栈统一(React + Webpack),但各自独立仓库、独立部署。产品希望:设计系统里的按钮、表格、图表组件能在各应用里共用,且改一处、处处生效,而不是每个项目 copy 一份或各维护各的。
换句话说:多应用共享组件成了刚需,而且要尽量少耦合发版节奏——中台发版不能绑死活动页的发版。
二、我们试过的方案与痛点
在接触模块联邦之前,我们试过两种常见做法,都遇到了明显的瓶颈。
2.1 方案一:NPM 发包
把设计系统打成 @company/design-system 发到内网 NPM,各应用 npm install 后按需引用。
痛点:
- 版本强耦合:设计系统修个 bug 或加个组件,要发一版 NPM,各应用再升级依赖、再构建发布,链条长、节奏难对齐。
- 多应用不同步:有的应用还在用旧版,有的已升级,线上会同时存在多版本,排查问题时要先看「当前应用装的是哪一版」。
- 发版心理负担:小改动也要走发包流程,大家更倾向于在业务项目里 copy 一份改,时间一长又变成多份实现。
2.2 方案二:iframe 嵌子应用
把「组件展示页」做成独立应用,主应用用 iframe 嵌进去。
痛点:
-
隔离过重:样式、主题、路由、登录态都要额外打通,通信靠
postMessage,心智负担大。 - 体验和性能:多一层 iframe,布局、滚动、弹窗都要特殊处理;首屏多一次文档加载,观感上也容易「慢一截」。
- 不适合「组件级」复用:我们更需要的是「在页面里嵌一个按钮、一个图表」,而不是「嵌一整页」,iframe 更适合整页级的隔离。
这两条路走下来,我们意识到:需要一种运行时按需拉取、独立构建部署、又能像本地模块一样用的机制。后来在 Webpack 5 的文档里看到了 Module Federation(模块联邦),和我们要的场景非常契合。
三、模块联邦是什么:一句话 + 三个角色
模块联邦是 Webpack 5 内置的能力,让多个独立构建、独立部署的应用,在运行时像用本地模块一样加载彼此的代码。不用先发 NPM、不用 npm install,只要构建时配置好「谁暴露、谁消费」,运行时就能动态拉取并执行。
三个角色可以这么记:
| 角色 | 做什么 |
|---|---|
| Remote(远程应用) | 通过 exposes 把组件/模块暴露出去,打包出 remoteEntry.js,供别人加载。 |
| Host(宿主应用) | 通过 remotes 配置 Remote 的入口地址,用 import('remoteName/Button') 消费。 |
| shared | 双方声明共享依赖(如 React),可配 singleton: true,保证只加载一份,避免多实例冲突。 |
我们落地的形态是:设计系统单独一个应用作为 Remote,打包并部署 remoteEntry.js;中台、活动页、看板等作为 Host,在需要的地方 import('designSystem/Button'),运行时从 CDN 拉取设计系统的 chunk,和本地代码一起跑在同一页面里。
四、我们怎么落地的:配置要点与坑
4.1 Remote 侧:暴露入口与 shared
设计系统项目里用 ModuleFederationPlugin 暴露组件,并和 Host 约定好 shared(React、ReactDOM 等)版本一致且设为单例,否则容易出现「Invalid hook call」之类的问题。
const { ModuleFederationPlugin } = require('webpack').container;
// Remote 的 webpack 配置片段
new ModuleFederationPlugin({
name: 'designSystem',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.jsx',
'./Table': './src/Table.jsx',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
注意:output.publicPath 必须能让 Host 正确拼出所有 chunk 的完整 URL(我们生产环境用 CDN 域名),否则运行时会 404。
4.2 Host 侧:配置 remotes 与动态加载
各业务应用在 Webpack 里配置 remotes 指向设计系统的 remoteEntry.js 地址(开发环境用本机或内网地址,生产用 CDN),然后用 React.lazy + Suspense 加载远程组件,对业务代码来说就像在用异步组件。
// Host 的 webpack 配置片段
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
designSystem: 'designSystem@https://cdn.example.com/design-system/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
});
// 业务里使用
const RemoteButton = lazy(() => import('designSystem/Button'));
<Suspense fallback={<Spin />}>
<RemoteButton>来自设计系统</RemoteButton>
</Suspense>
4.3 我们踩过的坑
-
publicPath:Remote 上线后若没配对,Host 拉 chunk 会 404,我们是在 CI 里把 Remote 的
publicPath打成当前 CDN 前缀。 -
shared 版本:Host 和 Remote 的
requiredVersion要兼容,否则可能加载两份 React,导致 hook 报错;我们统一用^18.0.0并锁大版本。 -
CORS:Remote 的静态资源要允许业务域名的 origin,我们 Nginx 里对
remoteEntry.js和 chunk 加了对应Access-Control-Allow-Origin。
五、结果与小结
落地模块联邦之后:设计系统单独发版、单独部署,各业务应用不用改依赖、不用重新装包,刷新页面即可拿到最新组件;多应用共享组件、发版解耦这两个目标都满足了。后续我们也在部分场景下用同一套机制做了「活动页作为 Remote、中台作为 Host」的集成,实现了一应用既可当 Host 也可当 Remote。
小结几句:
- 需求驱动:多应用共享组件、又要独立发版时,NPM 发包和 iframe 各有短板,模块联邦的「运行时拉取 + 独立构建部署」很贴这类场景。
-
核心三件套:Remote 用 exposes 暴露并产出
remoteEntry.js,Host 用 remotes 拉取并用import('remote/xx')消费,shared 配成单例避免多实例。 - 落地注意:publicPath、shared 版本、CORS 三点配好,再配合 CDN 和 CI,线上就能稳定跑。
如果你也在做多应用组件共享或微前端选型,希望这篇「从需求到落地」的小史能给你一点参考。更细的配置与手把手 Demo 可以看 Webpack 官方 Module Federation 文档 和 module-federation-examples。觉得有用的话,欢迎点赞、收藏或评论区聊聊你的场景。
【Three.js内存管理】那些你以为释放了,其实还在占着的资源
前言
你以为你
dispose了,它就没了吗?Too young ~
三个月前,我差点被一个 Bug 搞到怀疑人生。
事情是这样的:我负责的一个智慧园区项目,上线前测试同学跑过来说:“页面打开久了会卡,你瞅瞅?”
我打开页面,刚开始确实丝滑,60fps 稳稳的。然后我开始疯狂切页面、关弹窗、加载新模型……五分钟后再看帧率,30fps。十分钟后,15fps。二十分钟后,页面直接白屏,Chrome 弹出一个熟悉的提示:
“喔唁,崩溃啦。”
我懵了。
代码里明明写了 dispose(),该释放的都释放了,怎么还能崩?打开 Chrome 任务管理器一看,GPU 内存那一栏的数字,像坐了火箭一样往上涨,根本停不下来。
那天下午,我干了一件事:把所有以为释放了、实际还占着的资源,一个个揪出来。今天就把这些“装死”的资源全曝光,省得你们也踩坑。
第一个坑:几何体,你 dispose 了吗?
先看一段我当时的代码:
// 加载一个模型
loader.load('big-model.glb', (gltf) => {
const model = gltf.scene;
scene.add(model);
});
// 后来某个时刻,移除模型
scene.remove(model);
// 心想:移除就完事儿了,内存会自动释放吧?
天真的我,以为 remove 就万事大吉。结果呢?几何体数据还赖在 GPU 里不走。
正确做法:
// 移除前,遍历模型,dispose 所有几何体和材质
function disposeModel(model) {
model.traverse((obj) => {
if (obj.isMesh) {
if (obj.geometry) {
obj.geometry.dispose();
}
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
}
});
scene.remove(model);
}
你以为这就够了?太天真了。材质里的纹理呢?你不 dispose,它还在!
第二个坑:纹理,你 dispose 了吗?
材质 dispose 只会释放材质本身的 GPU 资源,但纹理是单独分配的。你得手动把纹理也干掉。
// 错误:只 dispose 材质
material.dispose(); // 纹理还在!
// 正确:先 dispose 纹理
if (material.map) material.map.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
// ... 还有 aoMap、emissiveMap、metalnessMap ...
material.dispose();
有一次我忘了 dispose 纹理,结果加载了 100 个不同的模型,每个模型都带一张 4K 贴图。你们猜 GPU 内存用了多少?直接爆了,页面黑屏。
更坑的是,有些纹理是多个材质共用的。如果你 dispose 了共用纹理,其他材质也跟着完蛋。所以必须做好引用计数,或者用 ResourceTracker 统一管理。
第三个坑:RenderTarget,你不 dispose 试试?
做后期处理的时候,经常用到 WebGLRenderTarget。比如 ping-pong buffer、阴影贴图、反射纹理……
const rt = new THREE.WebGLRenderTarget(1024, 1024);
// 用完之后,忘了 dispose
这个玩意儿,不 dispose 的话,显存占用一直不释放。而且你肉眼看不见,Chrome 任务管理器里 GPU 内存悄悄上涨。
正确:用完就扔。
rt.dispose();
特别是做动态效果,每帧新建一个 RenderTarget 又不释放,那内存涨得比股票还快。
第四个坑:InstancedMesh 的矩阵,你以为删了就没了?
InstancedMesh 是个好东西,能把成千上万个实例压缩成一个 Draw Call。但如果你动态增删实例,得小心。
// 创建
const instancedMesh = new THREE.InstancedMesh(geo, mat, 1000);
scene.add(instancedMesh);
// 后来想删掉一部分实例,直接把 count 改小?
instancedMesh.count = 500;
// 你以为剩下的 500 个实例的内存就释放了?
图样图森破。InstancedMesh 内部的矩阵缓冲区(instanceMatrix)还是 1000 的大小,只是渲染时只画前 500 个。那 500 个被“删掉”的实例数据还占着显存。
正确做法:重新创建一个新的 InstancedMesh,只保留需要的数量。或者更狠一点,自己维护一个动态数组,每帧重新上传矩阵。
第五个坑:BufferAttribute,你 dispose 了吗?
有时候我们手动创建几何体:
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([...]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
当你 geometry.dispose() 时,这些 BufferAttribute 也会被 dispose 吗?答案是:会,但前提是这些 attribute 没有被其他地方引用。
如果你把同一个 BufferAttribute 赋值给两个几何体,dispose 其中一个,另一个的 attribute 还在,但底层 GPU 缓冲区可能已经被释放了,导致另一个几何体渲染出错。
所以,共用 attribute 要小心,要么就别共用,要么就用引用计数自己管理。
第六个坑:Texture 的 image,你还得手动 revoke?
如果你用 URL.createObjectURL() 加载图片,比如从本地文件上传生成纹理:
const url = URL.createObjectURL(file);
const texture = loader.load(url);
// 用完 texture 后,dispose 了纹理,但 URL 还没释放
URL.revokeObjectURL(url) 得自己调用,否则内存泄漏。而且这个泄漏不在 GPU,而在 JS 堆里,Chrome 任务管理器看不出来,但用久了页面一样卡。
第七个坑:动画混合器,你 stop 了吗?
如果你用了 AnimationMixer 播放动画,直接移除模型而不停止动画,mixer 内部还有对模型的引用,导致模型无法被垃圾回收。
// 错误
scene.remove(model);
// mixer 还在引用 model 内部的骨骼、材质等
// 正确
mixer.stopAllAction();
mixer.uncacheRoot(model); // 重要!
scene.remove(model);
这个坑我踩过,找了半天才发现 mixer 偷偷摸摸抱着模型不放手。
第八个坑:画布弹窗,我把每帧都变成了内存地雷
这事儿说起来有点丢人,但为了大伙儿不重蹈覆辙,我还是交代了吧。
去年做第一个正式项目,有个需求:点击设备,弹出一个悬浮面板,显示实时数据。当时我年轻气盛,心想这弹窗得跟3D场景“天衣无缝”啊,用普通的HTML div多掉价,飘在画布上面,一点儿都不酷。
于是我想了个自认为很牛的办法:用 Sprite + Konva。
Konva 是个Canvas 2D库,可以在上面画各种UI元素。我把它画好的Canvas转成Three.js的 CanvasTexture,然后贴到 Sprite 材质上,再把 Sprite 放到3D空间里。完美!弹窗像模型一样存在于场景中,可以旋转、缩放,跟设备严丝合缝。
更让我得意的是,数据是实时更新的,比如温度、压力每秒都在变。我就写了个定时器,每秒重新画一次Konva画布,生成新的CanvasTexture,赋值给Sprite材质。
// 伪代码:每秒更新弹窗纹理
setInterval(() => {
// 1. 清空Konva画布,重新画UI
konvaLayer.clear();
konvaLayer.draw();
// 2. 把画布转成Three纹理
const canvas = konvaLayer.toCanvas();
const texture = new THREE.CanvasTexture(canvas);
// 3. 赋给Sprite
sprite.material.map = texture;
sprite.material.needsUpdate = true;
}, 1000);
刚开始测试,一切正常,数据跳动,弹窗灵动,我美滋滋地交付了。
然后噩梦开始了。
上线第一天,现场反馈:系统运行四个小时左右就崩溃了。我远程一看,页面白屏,Chrome报错“Out of Memory”。打开任务管理器,GPU内存已经顶到2GB(我的笔记本才4GB)。
我第一反应:是不是Konva画布太大?压缩图片,降低分辨率,从512x512降到256x256。重新上线,六小时崩溃。
我又想:是不是Canvas转纹理的时候没释放旧的?于是我加了一行:
if (sprite.material.map) sprite.material.map.dispose();
再上线,八小时崩溃。
我开始怀疑人生了。不断优化,不断测试,内存泄漏的时间从四小时延长到十二小时、十八小时,但始终无法根除。最后,我把Konva换成原生Canvas画图,自己管理画布,甚至手动调用 canvas.width = canvas.width 来清空,二十小时崩溃一次。
我盯着那个“二十小时崩溃”的数据,突然明白了一个道理:这条路,走不通。
问题到底出在哪儿?
后来用Chrome Memory面板拍快照对比,发现罪魁祸首有三个:
-
每秒钟新建一个CanvasTexture,旧的虽然调了
dispose,但底层的Canvas对象还在内存里,因为Konva的toCanvas()每次都会生成新的Canvas,这些Canvas被CanvasTexture引用着,无法释放。 -
Konva内部也有缓存,每次
draw都会产生新的离屏Canvas,虽然我调用了clear,但Konva为了性能,会保留一些内部对象,这些对象里又引用了画布。 -
Sprite材质每次重新赋值
map,旧纹理即使dispose了,也可能会被GPU管线延迟释放,积累多了就爆了。
折腾了两周,我最终做了一个耻辱的决定:放弃Sprite方案,改用普通HTML div。
就是那种最简单、最没技术含量的 position: absolute,通过CSS把div定位到画布上方,监听mousemove和intersect来更新位置。什么“天衣无缝”?去他的吧,不崩溃才是王道。
说来也怪,换了div之后,再也没崩过。内存稳如老狗,帧率也回来了。产品经理问:“为啥弹窗变成2D的了?”我面不改色:“这是最新设计风格,扁平化,通透。”
从那以后,我明白了一个道理:有时候“最优解”是幻觉,能稳定运行的方案才是真·最优解。
如果你也遇到类似的需求,听我一句劝:别在Sprite里玩动态画布,老老实实用HTML overlay。3D就该干3D的事,2D就该干2D的事,强行融合,只会让你半夜爬起来查内存泄漏。
教训:CanvasTexture 配合实时更新的画布,每帧都要注意释放旧的纹理,并且确保画布本身没有被意外引用。但如果可能,直接用HTML元素覆盖更简单可靠。
怎么查内存泄漏?
上面说了这么多,怎么发现自己项目里有泄漏?我总结了三个方法:
1. Chrome 任务管理器
按 Shift + Esc 打开,找到你的页面,看两列:
- 内存占用空间:JS 堆内存,如果持续增长,可能是 JS 对象没释放。
- GPU 内存:显存占用,如果持续增长,肯定是 Three.js 资源没 dispose。
2. 内存快照
Chrome DevTools -> Memory 面板,拍快照,对比两次之间的差异。可以过滤 Three. 关键词,看看哪些对象没被回收。
3. 写个简单的监控
在动画循环里定期打印 renderer.info.memory:
setInterval(() => {
console.log(renderer.info.memory);
}, 5000);
geometries、textures 的数量如果只增不减,那就是泄漏了。
最后的忠告:写个 ResourceTracker
被坑多了之后,我学聪明了:写一个统一的资源追踪器,所有几何体、材质、纹理、RenderTarget 都交给它管理。
class ResourceTracker {
constructor() {
this.resources = new Set();
}
track(resource) {
if (resource.dispose) {
this.resources.add(resource);
}
return resource;
}
disposeAll() {
this.resources.forEach(resource => {
if (resource.dispose) {
resource.dispose();
}
});
this.resources.clear();
}
}
// 使用
const tracker = new ResourceTracker();
const geometry = tracker.track(new THREE.BoxGeometry());
const material = tracker.track(new THREE.MeshStandardMaterial());
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// 销毁时
tracker.disposeAll();
scene.remove(mesh);
这样就不会漏掉任何资源了。
写在最后
那个让我崩溃一下午的 Bug,最后发现是 RenderTarget 忘了 dispose。一行代码的事,让我查了三个小时。
从那以后,我养成了一个习惯:每次写完一个功能,就打开 Chrome 任务管理器,盯着 GPU 内存看十秒。要是数字往上涨,就一个个排查,直到它稳定为止。
内存管理这玩意儿,不出事的时候你觉得它屁用没有,一出事它就让你怀疑人生。
所以,如果你也在写 Three.js,记住这句话:
你以为释放了的资源,99% 都还在那儿装死。
互动
你遇到过最隐蔽的内存泄漏是啥?评论区晒出来,让大伙一起避坑 😏
下篇预告:【Three.js 多相机渲染】如何在同一场景里实现“画中画”效果
从代码到智能体:MCP 协议如何重塑 AI Agent 的边界
摘要:当大模型遇上工具调用,AI Agent 的想象力被无限放大。本文通过一个真实的 MCP(Model Context Protocol)项目实战,深入剖析如何构建一个能自主调用地图、文件系统、浏览器 DevTools 的多模态智能体。我们将从代码细节出发,探讨 MCP 协议的架构设计思想、工具调用的执行机制,以及在实际开发中遇到的挑战与思考。这不仅是一篇技术教程,更是一次对 AI Agent 未来形态的深度探索。
一、引言:为什么我们需要 MCP?
在 LangChain、LlamaIndex 等框架的推动下,AI Agent 已经从概念走向落地。但一个核心问题始终存在:如何让大模型安全、高效、标准化地调用外部工具?
传统的做法是为每个工具编写自定义的 Function Calling 逻辑,导致代码耦合度高、复用性差、维护成本巨大。而 MCP(Model Context Protocol) 的出现,正是为了解决这一痛点。
MCP 是一个开放协议,旨在统一大模型与外部工具之间的通信标准。它允许开发者以声明式的方式注册工具,大模型则通过标准接口发现并调用这些工具,无需关心底层实现细节。
今天,我们就通过一个真实项目——mcp_in_action,来深入理解 MCP 如何在实际场景中发挥作用。
二、项目结构解析:模块化设计的艺术
首先,让我们看看项目的目录结构:
mcp_in_action/
└── mcp-test/
├── node_modules/
├── .env
├── beijing_south_station_hotels.md
├── main.mjs ← 核心入口文件
├── package.json
└── pnpm-lock.yaml
这是一个典型的 Node.js 项目,使用 pnpm 作为包管理器。核心逻辑集中在 main.mjs 文件中,采用 ES Module 语法(.mjs 后缀),体现了现代 JavaScript 开发的最佳实践。
2.1 环境配置:.env 文件的作用
.env文件中包含以下关键变量:
MODEL_NAME=gpt-4o
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.openai.com/v1
AMAP_MAPS_API_KEY=your_amap_key
这种将敏感信息与环境变量分离的做法,是云原生开发的基本准则,既保证了安全性,又提升了部署灵活性。
三、核心代码拆解:构建多服务器 MCP 客户端
3.1 初始化大模型
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL
}
});
这里使用了 LangChain 的 ChatOpenAI 类,支持自定义模型名称、API Key 和基础 URL。这种设计使得我们可以轻松切换不同的 LLM 提供商(如 Azure OpenAI、本地部署的 vLLM 等)。
3.2 创建 MultiServerMCPClient
这是整个项目的灵魂所在:
const mcpClient = new MultiServerMCPClient({
mcpServers: {
"amap-maps-streamableHTTP": {
url: `https://mcp.amap.com/mcp?key=${process.env.AMAP_MAPS_API_KEY}`
},
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"D:/Workspace/lesson_zp/ai/agent/mcp_in_action/mcp-test"
]
},
"chrome-devtools": {
"command": "npx",
"args": [
"-y",
"chrome-devtools-mcp@latest"
]
}
}
})
三种不同类型的 MCP 服务器:
-
远程 HTTP 服务(高德地图)
通过 URL 直接连接官方提供的 MCP 服务,适用于标准化、高可用的公共 API。 -
本地命令行服务(文件系统)
使用npx动态安装并运行@modelcontextprotocol/server-filesystem,指定工作目录为当前项目路径。这种方式非常适合需要访问本地资源的场景。 -
浏览器自动化服务(Chrome DevTools)
同样通过npx启动chrome-devtools-mcp,实现对浏览器的程序化控制。这是实现“视觉型 Agent”的关键。
💡 思考点:MCP 协议的强大之处在于它的异构兼容性。无论是 HTTP 服务、本地进程还是 WebSocket 连接,都可以被统一抽象为“MCP Server”,大模型只需关注工具的功能描述,无需关心通信协议。
3.3 获取工具并绑定到模型
const tools = await mcpClient.getTools();
const modelWithTools = model.bindTools(tools);
这两行代码完成了从“纯语言模型”到“增强型 Agent”的转变。getTools() 会向所有注册的 MCP 服务器发起 discovery 请求,收集可用工具列表;bindTools() 则将这些工具注入到模型的推理过程中,使其具备调用能力。
四、Agent 执行引擎:循环推理与工具调用
4.1 主循环逻辑
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [new HumanMessage(query)];
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen('⏳正在等待AI思考...'));
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n AI 最终回复:\n ${response.content}\n`);
return response.content;
}
// 处理工具调用...
}
}
这个函数实现了经典的 ReAct(Reason + Act) 模式:
- Reason:模型根据当前对话历史生成下一步动作(可能是直接回答,也可能是调用工具)。
-
Act:如果检测到
tool_calls,则逐个执行对应工具,并将结果以ToolMessage形式反馈给模型。 - Loop:重复上述过程,直到模型不再调用工具或达到最大迭代次数。
4.2 工具调用处理细节
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
const toolResult = await foundTool.invoke(toolCall.args);
let contentStr;
if (typeof toolResult === 'string') {
contentStr = toolResult;
} else if (toolResult && toolResult.text) {
contentStr = toolResult.text;
}
messages.push(new ToolMessage({
content: contentStr,
tool_call_id: toolCall.id
}));
}
}
这里有几个值得注意的设计:
-
容错机制:通过
find查找工具,避免不存在的工具调用导致崩溃。 -
结果标准化:无论工具返回的是字符串还是对象(含
text字段),都统一转换为字符串内容,确保消息格式一致。 -
Traceability:保留
tool_call_id,便于后续调试和审计。
五、实战案例:从需求到执行的完整链路
最后,我们来看一个具体的任务:
await runAgentWithTools(`
北京南站附近的3个酒店,拿到酒店图片,展开浏览器,展示每个酒店的图片,
每个tab一个url展示,并且把那个页面标题改为酒店名
`)
这个看似简单的自然语言指令,背后涉及多个复杂步骤:
- 地理搜索:调用高德地图 MCP 服务,查询“北京南站附近酒店”。
- 数据提取:从返回结果中提取酒店名称、地址、图片 URL 等信息。
- 浏览器控制:启动 Chrome DevTools MCP,打开新标签页加载图片。
-
DOM 操作:修改每个标签页的
<title>元素为对应酒店名。 - 状态同步:将所有操作结果反馈给模型,形成闭环。
🤯 震撼之处:整个过程完全由大模型自主规划!开发者只需定义工具能力,无需编写任何业务流程代码。这就是 MCP + LLM 带来的范式革命。
六、深度思考:MCP 的未来与挑战
6.1 优势总结
- 解耦性强:工具开发与 Agent 逻辑分离,团队协作更高效。
- 可扩展性好:新增工具只需注册 MCP Server,无需修改核心代码。
- 生态丰富:已有文件系统、数据库、浏览器、地图等多种官方/社区服务器。
- 安全性提升:工具权限可控,避免大模型随意执行危险操作。
6.2 现存挑战
- 性能开销:每次工具调用都需要网络/进程间通信,延迟较高。
- 错误处理复杂:工具失败时如何优雅降级?是否需要重试机制?
- 上下文爆炸:多次工具调用会导致 message 数组急剧膨胀,超出模型 context window。
- 调试困难:分布式架构下,定位问题需要跨多个服务日志追踪。
6.3 未来展望
我认为,MCP 将成为 AI Agent 领域的“USB 接口”——一种即插即用的标准协议。未来的趋势可能包括:
- 可视化编排:通过低代码平台拖拽组合不同 MCP 服务,快速构建垂直领域 Agent。
- 边缘计算集成:在 IoT 设备上运行轻量级 MCP Server,实现端侧智能。
- 多模态融合:结合语音、图像、视频等输入输出,打造真正的“全能助手”。
- 自治进化:Agent 能够自我发现新工具、优化调用策略,甚至参与 MCP 协议演进。
七、结语:站在巨人的肩膀上
回顾整个项目,最令我感触的不是代码本身,而是它所代表的工程哲学:
不要重复造轮子,而要善于组装轮子。
MCP 协议让我们站在了一个更高的起点上。我们不再需要从零开始实现每一个工具调用逻辑,而是可以专注于更高阶的问题:如何让 AI 更好地理解人类意图?如何设计更自然的交互界面?如何构建可信、可靠、可持续的智能系统?
这才是 AI Agent 真正的价值所在。
附录:延伸学习资源
作者寄语:如果你也被这个项目启发,不妨动手尝试搭建自己的 MCP Agent。记住,最好的学习方式就是亲手敲下一行行代码,在调试中成长,在失败中进步。AI 的未来,属于每一个敢于探索的实践者。
欢迎在评论区分享你的 MCP 实战经验,或者提出你遇到的难题。让我们一起推动 AI Agent 技术的普及与发展!
这一招让 Node 后端服务启动速度提升 75%!
一个Node 后端项目的启动方式可以分类为三种:
- 由源代码直接启动,如
tsx src/server.ts - 由tsc简单转译,如
tsc编译后node dist/server.js - 使用一些bundler进行打包,将其打包为单个文件,如
esbuild --bundle后node bundle.mjs
很多人其实并不知道这几种方法之间的区别,今天我想通过具体的测试来区分每种方法的不同。
测试目的
把测试拆成两个维度:
- 启动阶段性能
- 服务运行阶段性能
因为,我们可以测试三类数据:
- 冷启动会差多少?
- 稳态吞吐/延迟会差多少?
- 资源占用是否存在显著差异?
测试方法
环境
- macOS arm64
- Apple M5 / 10 cores
- 24GB RAM
- Node v22.22.0
- Express 5.1(TypeScript)
三种模式使用同一份 src/server.ts,业务逻辑完全一致,包含基础路由和“mock业务形态”路由:
-
基础路由(baseline)(参考了一个比较Express4/5版本速度差异的测试方法)
- GET /ping:返回 "pong",用于测最小路径开销
- GET /middlewares:挂 50 层 no-op middleware 后返回 { ok: true }
- GET /json:返回预生成的约 50KB JSON(固定内容,避免每次动态生成噪声)
- GET /payload:返回预生成的 100KB 文本
-
业务形态路由(realistic)
- GET /route1/info
- GET /route2/stats
- GET /route3/catalog
- GET /route4/summary(聚合 route1~3 的服务输出)
- GET /orm/users(走 Drizzle ORM 查询路径)
ORM 使用 drizzle-orm/sqlite-proxy + 内存数据模拟,不依赖外部数据库,尽量隔离网络与 DB 抖动对对比的干扰。
压测与采样口径
- 压测命令:
ab -k -n 200000 -c 100 - 每个场景重复 5 次,取均值
- 冷启动定义:从进程
spawn到/ping首个200 - 资源采样:压测期间每秒采样
RSS和CPU%
总体结果
1)冷启动
| mode | cold(ms) |
|---|---|
| esbuild | 104 |
| tsc | 308 |
| tsx | 399 |
![]()
冷启动的差异非常明显,esbuild 在冷启动上的表现优于 tsc 和 tsx,差距达到 75%。
2)平均吞吐(9 场景)
| mode | avg req/s |
|---|---|
| tsc | 22114 |
| esbuild | 21959 |
| tsx | 21928 |
吞吐量差异小于 1%,可见三者在稳态性能上几乎一致。
![]()
3)P95 延迟
三种方式的 P95 延迟几乎完全相同,均为 5-6ms。
![]()
4)RSS 内存
三者的内存使用几乎一致,均约为 62MB。
![]()
测试数据报告:链接
关键问题 1:为什么冷启动差这么多?
冷启动时间的差异可以拆解为以下几个部分:
-
模块图加载与文件 IO
a. 解析
import图-
静态分析:Node.js 会分析你的 JavaScript 文件中的
import语句,确定需要加载的模块。这是一个静态分析过程,Node.js 会在执行之前构建模块的依赖关系图(import图)。这有助于了解哪些模块需要加载,并准备好这些模块的依赖。
b. 读取文件
-
加载文件:当 Node.js 发现一个
import语句,它会根据静态分析结果读取相应的文件内容。如果该模块是一个 JavaScript 文件(.js或.mjs),Node.js 会读取文件的内容并将其解析为 JavaScript 代码。 - 查找模块:Node.js 会查找模块文件的位置,如果模块没有缓存,它会从磁盘读取相应文件。
c. 构建模块缓存
- 模块缓存:Node.js 会缓存已加载的模块,这样在多次加载同一个模块时,Node.js 不需要重新执行该模块的代码。这样可以提高性能,避免重复加载和执行相同的模块。
-
导出模块:在加载并执行完模块后,Node.js 会将模块的导出结果(
module.exports或export)存入缓存中,以便后续调用。
-
静态分析:Node.js 会分析你的 JavaScript 文件中的
在不同的启动方式下,加载的模块数量和方式有所不同:
-
tsc:编译多个 JS 文件。 -
tsx:编译多个 TS 文件。 -
esbuild:生成单一的打包文件。
esbuild 通过打包将多个模块合并为一个文件,减少了模块解析和文件 I/O 的开销,因此冷启动时间显著较短。
- 运行时转译成本(仅适用于 tsx)
tsx 使用 esbuild 编译 TypeScript 和 ESM,还会生成 source map 并内联到代码中。每次启动时,tsx 需要额外进行源代码映射,导致启动速度较慢。而 tsc 编译的是纯 JavaScript,Node.js 不需要做任何 TypeScript 转换,启动速度较快。
冷启动的结论
边缘计算、Serverless、短生命周期容器、CLI 工具,这种场景下,冷启动的速度至关重要,那使用esbuild等打包工具提前bundler而带来的冷启动优势是实打实的。
如果是常驻 API 服务,冷启动只发生一次,意义有限。
但是天下毕竟没有免费的午餐。使用第三方bundle工具提前bundle是不是也有一些坏处呢?是的。
第一点,不支持一些 TypeScript 特性。如esbuild不支持保留如eval()语法,还有就是不支持某些tsconfig.json属性,如emitDecoratorMetadata。
第二点,调试难度加大。esbuild 生成的代码通常会做大量的代码压缩、优化和打包,这使得调试变得比较困难。因为调试时的代码结构与原始源代码有很大的差异。如线上报错,开发人员可能需要额外的源映射(source maps)和调试工具来简化调试过程。
第三点,就是启动时,会有更大的cpu运行开销,请看下一节。
关键问题 2:为什么 CPU 峰值差异大?
CPU 峰值均值:
| mode | peak CPU% |
|---|---|
| tsx | 5.84 |
| tsc | 9.13 |
| esbuild | 11.89 |
这看起来 esbuild 更“耗 CPU”。但吞吐几乎一样。这说明什么?
一个可能解释是:在使用 esbuild 打包后的代码中,代码结构变得更加紧凑,启动时可能会大量导入很多原来零散的js模块等,某些常见的函数或代码路径可能会执行得更加频繁。
由于 JIT 编译机制,V8 可能会更快地识别出这些频繁执行的代码,并对其进行优化。这个优化过程又叫热点编译。
V8 JIT(即时编译)
- V8 是 Chrome 和 Node.js 中使用的 JavaScript 引擎,它使用 JIT(即时编译,Just-In-Time Compilation) 技术将 JavaScript 代码在运行时编译成机器代码,来提高执行效率。
- JIT 编译的目的是将频繁执行的代码(即“热点代码”)优化成更高效的机器代码,从而提升性能。
热点编译(Hotspot Compilation) :
- 当你执行一段 JavaScript 代码时,V8 会在开始时使用 解释执行(即不进行优化的方式)来快速运行代码。
- 如果某段代码被执行得非常频繁(即“热点代码”),V8 会将它标记为热点代码,并对其进行优化。
- 这时,V8 会在后台将热点代码编译为更高效的机器码,称为 热点编译。这通常会提高执行速度,但也可能带来一些额外的 CPU 开销。
在 V8 进行热点编译时,它需要使用 CPU 来分析和优化这些热点代码。这通常会导致短时间内 CPU 使用率升高,表现为 CPU 使用率的“抬高”。
另外很重要的一点,可以看到上面的统计图表中,重要的一点是,吞吐量几乎一致,这意味着无论是 tsx、tsc 还是 esbuild,在处理请求时的效率差异都很小。如果 esbuild 确实比其他模式更高效,它的吞吐量应该显著超过其他模式。然而,实际数据表明,差距微乎其微,这表明 CPU 峰值差异 主要来源于 短期的计算开销,而非整体的运行效率差异。
最终性能,尤其是吞吐量,在底层上受 V8 引擎优化和 I/O 处理 等因素的影响更大,在运行层面上应该受到业务逻辑、IO、JSON 序列化、数据库等因素决定。
小结
现在可以回答问题Node 后端服务启动方式的问题了:
在开发环境,生产环境(常驻 API 服务),还是推荐 tsx。性能差别不大,带来了更好的体验。
在如云函数等,冷启动敏感场景,推荐用如 esbuild来提前bundle,本文中的案例,esbuild的冷启动时长比普通tsx快了75%!
Vue实例与数据绑定
Vue实例与数据绑定
如果说Vue是一座大厦,那么Vue实例就是这座大厦的地基。地基打得牢,大厦才能稳。
在上一篇文章中,我们成功搭建了开发环境,并写出了第一个Vue应用。今天,让我们深入理解Vue的核心——Vue实例与数据绑定。
📌 写作约定:本系列文章以 Vue 3
<script setup>语法糖 为主要讲解方式,这是Vue 3.2+官方推荐的写法。同时会顺带介绍Vue 2和Vue 3 Options API的写法作为对比,帮助大家理解演进过程和维护老项目。
一、Vue实例:应用的"大脑"
每个Vue应用都从一个Vue实例开始。你可以把它想象成应用的"大脑",它管理着数据、方法和整个应用的生命周期。
1.1 创建Vue实例
在Vue 3中,创建应用实例的方式:
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
1.2 组件的"五脏六腑"
一个完整的Vue组件可以包含以下部分。先看Vue 3 <script setup>语法糖写法(推荐):
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
// =================== 数据:组件的"记忆" ===================
const count = ref(0)
const user = ref({ name: '张三', age: 25 })
const items = ref(['苹果', '香蕉', '橙子'])
// =================== 计算属性:组件的"派生数据" ===================
const doubleCount = computed(() => count.value * 2)
const fullName = computed(() => `${user.value.name}(${user.value.age}岁)`)
// =================== 侦听器:组件的"观察员" ===================
watch(count, (newVal, oldVal) => {
console.log(`count从${oldVal}变成了${newVal}`)
})
// =================== 方法:组件的"行为" ===================
const increment = () => {
count.value++
}
const greet = (name) => {
return `你好,${name}!`
}
// =================== 生命周期钩子 ===================
onMounted(() => {
console.log('DOM挂载完成')
})
</script>
对比Vue 3 Options API写法:
<script>
export default {
data() {
return {
count: 0,
user: { name: '张三', age: 25 },
items: ['苹果', '香蕉', '橙子']
}
},
computed: {
doubleCount() {
return this.count * 2
},
fullName() {
return `${this.user.name}(${this.user.age}岁)`
}
},
watch: {
count(newVal, oldVal) {
console.log(`count从${oldVal}变成了${newVal}`)
}
},
methods: {
increment() {
this.count++
},
greet(name) {
return `你好,${name}!`
}
},
mounted() {
console.log('DOM挂载完成')
}
}
</script>
对比Vue 2写法(已过时,了解即可):
<script>
export default {
data() {
return {
count: 0
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
},
mounted() {
console.log('DOM挂载完成')
}
}
</script>
1.3 三种写法对比总结
| 特性 | Vue 3 <script setup>
|
Vue 3 Options API | Vue 2 |
|---|---|---|---|
| 代码量 | 最少 | 较多 | 较多 |
| this | 不需要 | 需要 | 需要 |
| 类型推断 | 优秀 | 一般 | 弱 |
| 学习曲线 | 中等 | 低 | 低 |
| 官方推荐 | ✅ 推荐 | 兼容维护 | ❌ 已停止维护 |
1.4 关于this的烦恼
在<script setup>语法糖中,不需要使用this,直接使用响应式变量即可:
<script setup>
import { ref } from 'vue'
const count = ref(0)
const increment = () => {
count.value++ // ✅ 直接访问
console.log(count.value)
}
const log = () => {
console.log(count.value)
}
const doBoth = () => {
increment() // ✅ 直接调用
log()
}
</script>
而在Options API中,需要通过this访问:
export default {
data() {
return { count: 0 }
},
methods: {
increment() {
this.count++ // 需要this
this.log() // 需要this
},
log() {
console.log(this.count)
}
}
}
Options API的常见陷阱:箭头函数没有自己的this:
export default {
data() {
return { count: 0 }
},
methods: {
// ❌ 错误:箭头函数的this不指向Vue实例
wrongIncrement: () => {
this.count++ // 报错!
},
// ✅ 正确:普通函数
correctIncrement() {
this.count++
}
}
}
💡
<script setup>的优势:彻底告别this的烦恼,代码更简洁,类型推断更友好。
二、生命周期:Vue实例的"人生旅程"
每个Vue实例都有完整的生命周期——从创建到销毁,就像人的一生。理解生命周期,你就能在正确的时机做正确的事。
2.1 生命周期全景图
┌─────────────────────────────────────────────────────────────┐
│ Vue 3 生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 创建阶段 │
│ ┌─────────────┐ │
│ │ setup() │ ← <script setup>中的代码直接执行 │
│ └─────────────┘ 相当于 beforeCreate + created │
│ │
│ 挂载阶段 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ onBefore │───▶│ onMounted │ │
│ │ Mount │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ DOM已挂载 │
│ │ 可访问DOM元素 │
│ │ 适合发起网络请求 │
│ │
│ 更新阶段(数据变化时触发) │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ onBefore │───▶│ onUpdated │ │
│ │ Update │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │ │
│ DOM已更新 │
│ │
│ 卸载阶段 │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ onBefore │───▶│ onUnmounted │ │
│ │ Unmount │ │ │ │
│ └─────────────┘ └─────────────┘ │
│ │ │
│ 实例已销毁 │
│ 清理定时器、事件监听器 │
│ │
└─────────────────────────────────────────────────────────────┘
2.2 常用生命周期钩子
Vue 3 <script setup> 写法(推荐):
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'
const count = ref(0)
let timer = null
// =================== setup阶段:代码直接执行 ===================
// 相当于 created,数据已初始化,可访问响应式数据
console.log('组件创建完成')
// =================== onMounted:DOM已经渲染完成 ===================
onMounted(() => {
console.log('DOM挂载完成,可以访问DOM元素')
timer = setInterval(() => {
console.log('定时器运行中...')
}, 1000)
})
// =================== onUpdated:数据变化导致DOM更新后 ===================
onUpdated(() => {
console.log('DOM更新完成')
})
// =================== onUnmounted:组件已卸载 ===================
onUnmounted(() => {
console.log('组件已卸载')
clearInterval(timer) // 重要:清理定时器
})
</script>
对比Vue 3 Options API写法:
<script>
export default {
data() {
return { count: 0 }
},
created() {
console.log('组件创建完成')
},
mounted() {
console.log('DOM挂载完成')
},
updated() {
console.log('DOM更新完成')
},
beforeUnmount() { // Vue 3改名了
console.log('组件即将卸载')
},
unmounted() { // Vue 3改名了
console.log('组件已卸载')
}
}
</script>
对比Vue 2写法:
<script>
export default {
data() {
return { count: 0 }
},
created() {
console.log('组件创建完成')
},
mounted() {
console.log('DOM挂载完成')
},
beforeDestroy() { // Vue 2叫这个
console.log('组件即将销毁')
},
destroyed() { // Vue 2叫这个
console.log('组件已销毁')
}
}
</script>
2.3 生命周期钩子对照表
<script setup> |
Options API (Vue 3) | Options API (Vue 2) | 触发时机 |
|---|---|---|---|
| 代码直接执行 | created |
created |
实例创建完成 |
onBeforeMount |
beforeMount |
beforeMount |
DOM挂载前 |
onMounted |
mounted |
mounted |
DOM挂载完成 |
onBeforeUpdate |
beforeUpdate |
beforeUpdate |
数据变化DOM更新前 |
onUpdated |
updated |
updated |
DOM更新完成 |
onBeforeUnmount |
beforeUnmount |
beforeDestroy |
实例卸载前 |
onUnmounted |
unmounted |
destroyed |
实例卸载后 |
2.4 使用场景速查
| 场景 | 推荐钩子 | 示例 |
|---|---|---|
| 发起API请求 |
onMounted 或直接执行 |
获取初始数据 |
| 操作DOM | onMounted |
初始化图表库 |
| 设置定时器 | onMounted |
轮询、倒计时 |
| 清理定时器 | onUnmounted |
防止内存泄漏 |
| 监听窗口事件 |
onMounted + onUnmounted
|
resize、scroll |
三、响应式数据:Vue的"魔法"
响应式数据是Vue最核心的特性,它让数据和视图自动保持同步。
3.1 响应式原理简介
Vue 3使用Proxy实现响应式,Vue 2使用Object.defineProperty:
-
Vue 2:给对象的每个属性装"监控器",新增属性需要用
Vue.set() - Vue 3:给整个对象请"管家",新增属性自动响应式
3.2 ref vs reactive
Vue 3 <script setup> 写法:
<script setup>
import { ref, reactive } from 'vue'
// =================== ref:万能选择 ===================
const count = ref(0)
const name = ref('张三')
const user = ref({ age: 25 }) // 对象也可以用ref
// 访问和修改需要 .value
console.log(count.value) // 读取
count.value++ // 修改
user.value.age = 26 // 修改对象属性
// =================== reactive:仅用于对象/数组 ===================
const state = reactive({
name: '李四',
age: 25,
hobbies: ['编程', '阅读']
})
// 不需要 .value
console.log(state.name) // 读取
state.age++ // 修改
state.hobbies.push('游戏') // 修改数组
</script>
<template>
<!-- 模板中ref自动解包,不需要.value -->
<p>{{ count }}</p>
<p>{{ state.name }}</p>
</template>
选择建议:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基本类型 | ref |
reactive不支持基本类型 |
| 对象 |
ref 或 reactive
|
都可以,ref更统一 |
| 需要整体替换 | ref |
state.value = newObj |
| 解构需求 |
reactive + toRefs
|
保持响应性 |
3.3 响应式陷阱与解决
陷阱一:解构丢失响应性
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({
name: '张三',
age: 25
})
// ❌ 错误:解构后失去响应性
const { name, age } = state
// ✅ 正确:使用toRefs保持响应性
const { name, age } = toRefs(state)
</script>
陷阱二:reactive被整体替换
<script setup>
import { reactive } from 'vue'
const state = reactive({ count: 0 })
// ❌ 错误:整体替换会丢失响应性
const wrongReset = () => {
state = { count: 0 } // state不再是响应式的
}
// ✅ 正确:修改属性
const rightReset = () => {
state.count = 0
}
</script>
陷阱三:ref在模板中的自动解包
<script setup>
import { ref } from 'vue'
const count = ref(0)
const user = ref({ name: '张三' })
</script>
<template>
<!-- ✅ 正确:自动解包 -->
<p>{{ count }}</p>
<p>{{ user.name }}</p>
<!-- ❌ 错误:不需要.value -->
<p>{{ count.value }}</p>
</template>
四、计算属性:数据的"变形金刚"
计算属性根据已有数据派生新数据,只有依赖变化时才重新计算,具有缓存特性。
4.1 基本用法
Vue 3 <script setup> 写法:
<template>
<p>总价:{{ totalPrice }}</p>
<p>双倍:{{ doubleCount }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const quantity = ref(2)
const discount = ref(0.8)
const count = ref(5)
// =================== 计算属性:有缓存 ===================
const totalPrice = computed(() => {
console.log('计算属性执行了') // 依赖不变就不会再执行
return price.value * quantity.value * discount.value
})
const doubleCount = computed(() => count.value * 2)
</script>
对比Vue 3 Options API写法:
export default {
data() {
return {
price: 100,
quantity: 2,
discount: 0.8
}
},
computed: {
totalPrice() {
return this.price * this.quantity * this.discount
}
}
}
4.2 计算属性 vs 方法
<template>
<!-- 计算属性:有缓存,多次访问只计算一次 -->
<p>{{ totalPrice }}</p>
<p>{{ totalPrice }}</p>
<!-- 方法:每次调用都执行 -->
<p>{{ getTotalPrice() }}</p>
<p>{{ getTotalPrice() }}</p>
</template>
<script setup>
import { ref, computed } from 'vue'
const price = ref(100)
const totalPrice = computed(() => {
console.log('计算属性执行')
return price.value * 2
})
const getTotalPrice = () => {
console.log('方法执行')
return price.value * 2
}
</script>
4.3 可写计算属性
计算属性默认只读,但也可以设置setter:
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
// =================== 可写计算属性 ===================
const fullName = computed({
get() {
return `${firstName.value}${lastName.value}`
},
set(value) {
firstName.value = value.charAt(0)
lastName.value = value.slice(1)
}
})
// 使用setter
const changeName = () => {
fullName.value = '李四' // 自动拆分为 firstName='李', lastName='四'
}
</script>
五、侦听器:数据的"守门员"
侦听器用于在数据变化时执行异步或开销较大的操作。
5.1 基本用法
Vue 3 <script setup> 写法:
<script setup>
import { ref, watch } from 'vue'
const searchKeyword = ref('')
const searchResults = ref([])
// =================== 监听ref ===================
watch(searchKeyword, (newVal, oldVal) => {
console.log(`从 "${oldVal}" 变为 "${newVal}"`)
searchResults.value = []
})
</script>
对比Vue 3 Options API写法:
export default {
data() {
return {
searchKeyword: '',
searchResults: []
}
},
watch: {
searchKeyword(newVal, oldVal) {
console.log(`从 "${oldVal}" 变为 "${newVal}"`)
this.searchResults = []
}
}
}
5.2 监听选项
<script setup>
import { ref, watch } from 'vue'
const searchKeyword = ref('')
watch(searchKeyword, (newVal) => {
console.log('搜索:', newVal)
}, {
immediate: true, // 立即执行一次
deep: false, // 深度监听(用于对象)
flush: 'post' // DOM更新后执行
})
</script>
5.3 监听对象属性
<script setup>
import { ref, reactive, watch } from 'vue'
// =================== 监听ref对象的属性 ===================
const user = ref({
name: '张三',
profile: { age: 25 }
})
// 方式一:getter函数
watch(() => user.value.name, (newVal) => {
console.log('名字变了:', newVal)
})
// 方式二:深度监听整个对象
watch(user, (newVal) => {
console.log('user变了')
}, { deep: true })
// 方式三:监听嵌套属性
watch(() => user.value.profile.age, (newVal) => {
console.log('年龄变了:', newVal)
})
// =================== 监听reactive对象 ===================
const state = reactive({
count: 0,
user: { name: '李四' }
})
// reactive的属性可以直接监听
watch(() => state.count, (newVal) => {
console.log('count变了:', newVal)
})
// 监听整个reactive对象(自动deep)
watch(state, (newVal) => {
console.log('state变了')
})
</script>
5.4 实战:搜索防抖
<template>
<input v-model="keyword" placeholder="搜索..." />
<div v-if="loading">搜索中...</div>
<ul v-else>
<li v-for="item in results" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
const results = ref([])
const loading = ref(false)
let timer = null
watch(keyword, (newVal) => {
clearTimeout(timer)
timer = setTimeout(async () => {
if (!newVal.trim()) {
results.value = []
return
}
loading.value = true
// 模拟API请求
await new Promise(r => setTimeout(r, 300))
results.value = [
{ id: 1, name: `${newVal}结果1` },
{ id: 2, name: `${newVal}结果2` }
]
loading.value = false
}, 500) // 防抖500ms
})
</script>
5.5 watchEffect:自动追踪依赖
Vue 3还提供了watchEffect,自动追踪回调中使用的响应式数据:
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const name = ref('张三')
// 自动追踪:用到谁就监听谁
watchEffect(() => {
console.log(`count=${count.value}, name=${name.value}`)
// count或name变化都会触发
})
</script>
六、计算属性 vs 侦听器:如何选择?
6.1 对比总结
| 特性 | 计算属性 | 侦听器 |
|---|---|---|
| 返回值 | 必须返回 | 可选 |
| 缓存 | ✅ 有 | ❌ 无 |
| 异步 | ❌ 不支持 | ✅ 支持 |
| 适用场景 | 数据派生、格式化 | 异步请求、副作用 |
6.2 选择指南
用计算属性:
- 根据已有数据计算新数据
- 需要缓存避免重复计算
- 纯函数,无副作用
<script setup>
import { ref, computed } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const list = ref([{ id: 1, active: true }])
// ✅ 适合计算属性
const fullName = computed(() => `${firstName.value}${lastName.value}`)
const activeList = computed(() => list.value.filter(i => i.active))
</script>
用侦听器:
- 需要执行异步操作
- 数据变化时执行副作用
- 需要比较新旧值
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
const userId = ref(1)
// ✅ 适合侦听器:异步请求
watch(keyword, (val) => {
fetchResults(val)
})
// ✅ 适合侦听器:比较新旧值
watch(userId, (newVal, oldVal) => {
if (newVal !== oldVal) {
fetchUser(newVal)
}
})
</script>
七、实战案例:用户管理
综合运用所学知识,用Vue 3 <script setup> 实现一个用户管理组件:
<template>
<div class="user-manager">
<h2>用户管理</h2>
<!-- 添加用户 -->
<div class="add-section">
<input
v-model="newName"
placeholder="输入用户名"
@keyup.enter="addUser"
/>
<button @click="addUser" :disabled="!canAdd">添加</button>
</div>
<!-- 搜索 -->
<div class="search-section">
<input v-model="keyword" placeholder="搜索用户..." />
</div>
<!-- 统计 -->
<div class="stats">
<span>总数:{{ users.length }}</span>
<span>活跃:{{ activeCount }}</span>
<span>结果:{{ filteredUsers.length }}</span>
</div>
<!-- 用户列表 -->
<ul class="user-list">
<li
v-for="user in filteredUsers"
:key="user.id"
:class="{ active: user.isActive }"
>
<span>{{ user.name }}</span>
<span class="status" @click="toggleStatus(user)">
{{ user.isActive ? '🟢' : '🔴' }}
</span>
<button @click="removeUser(user.id)">删除</button>
</li>
</ul>
<div v-if="users.length === 0" class="empty">暂无用户</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
// =================== 数据 ===================
const users = ref([
{ id: 1, name: '张三', isActive: true },
{ id: 2, name: '李四', isActive: false },
{ id: 3, name: '王五', isActive: true }
])
const newName = ref('')
const keyword = ref('')
let nextId = 4
// =================== 计算属性 ===================
const canAdd = computed(() => newName.value.trim().length >= 2)
const activeCount = computed(() =>
users.value.filter(u => u.isActive).length
)
const filteredUsers = computed(() => {
if (!keyword.value.trim()) return users.value
const kw = keyword.value.toLowerCase()
return users.value.filter(u =>
u.name.toLowerCase().includes(kw)
)
})
// =================== 侦听器 ===================
watch(users, (val) => {
localStorage.setItem('users', JSON.stringify(val))
}, { deep: true })
// =================== 生命周期 ===================
onMounted(() => {
const saved = localStorage.getItem('users')
if (saved) users.value = JSON.parse(saved)
})
// =================== 方法 ===================
const addUser = () => {
if (!canAdd.value) return
users.value.push({
id: nextId++,
name: newName.value.trim(),
isActive: false
})
newName.value = ''
}
const removeUser = (id) => {
const idx = users.value.findIndex(u => u.id === id)
if (idx > -1) users.value.splice(idx, 1)
}
const toggleStatus = (user) => {
user.isActive = !user.isActive
}
</script>
<style scoped>
.user-manager {
max-width: 400px;
margin: 20px auto;
padding: 20px;
font-family: system-ui, sans-serif;
}
h2 { color: #42b983; text-align: center; }
.add-section, .search-section {
display: flex;
gap: 10px;
margin: 15px 0;
}
input {
flex: 1;
padding: 8px 12px;
border: 2px solid #ddd;
border-radius: 6px;
}
input:focus {
outline: none;
border-color: #42b983;
}
button {
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:disabled { background: #ccc; cursor: not-allowed; }
.stats {
display: flex;
justify-content: space-between;
padding: 10px;
background: #f5f5f5;
border-radius: 6px;
font-size: 14px;
}
.user-list {
list-style: none;
padding: 0;
}
.user-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
margin: 8px 0;
background: #f9f9f9;
border-radius: 6px;
}
.user-list li.active {
background: #f0fdf4;
border-left: 3px solid #42b983;
}
.status { cursor: pointer; }
.empty {
text-align: center;
color: #999;
padding: 30px;
}
</style>
八、总结
今天我们深入学习了Vue实例与数据绑定,核心要点:
| 主题 |
<script setup> 写法 |
关键点 |
|---|---|---|
| 数据 |
ref() / reactive()
|
ref需要.value,reactive不需要 |
| 计算属性 | computed(() => {}) |
有缓存,适合数据派生 |
| 侦听器 | watch(source, callback) |
支持异步,适合副作用 |
| 生命周期 |
onMounted() 等 |
setup阶段直接执行代码 |
记住这些要点:
- 新项目推荐使用
<script setup>语法糖 -
ref是万能选择,reactive仅用于对象 - 能用计算属性就不用侦听器
- 在
onUnmounted中清理副作用
下一站预告
在下一篇文章《模板语法与指令详解》中,我们将学习:
- 模板语法详解
- 常用指令(v-if、v-for、v-bind等)
- 自定义指令开发
敬请期待!
作者:洋洋技术笔记
发布日期:2026-02-28
系列:Vue.js从入门到精通 - 第2篇
zzy-scroll-timer:一个跨框架的滚动定时器插件
zzy-scroll-timer:一个跨框架的滚动定时器插件
![]()
前言
在前端开发中,滚动列表是一个常见的需求场景,比如公告栏滚动、新闻轮播、数据展示等。虽然市面上有不少轮播组件,但专门针对上下滚动且支持多框架的轻量级插件却不多见。
今天给大家分享一个我开发的跨框架滚动定时器插件 —— zzy-scroll-timer,它支持 Vue 2、Vue 3 和 React,使用简单,功能强大。
项目介绍
zzy-scroll-timer 是一个轻量级的滚动定时器插件,主要特点:
- ✅ 跨框架支持:同时支持 Vue 2、Vue 3 和 React
- ✅ 双向滚动:支持向上和向下两种滚动方向
- ✅ 自动/手动控制:支持自动开始滚动,也支持手动控制
- ✅ 动态参数更新:所有参数都支持动态修改
- ✅ TypeScript 支持:完整的类型定义
- ✅ 轻量级:核心代码简洁,无额外依赖
安装
npm install zzy-scroll-timer
使用方法
Vue 3
<template>
<ZScrollTimerVue3 :interval="2000" direction="up">
<ZScrollTimerItem>
<div class="item">Item 1</div>
</ZScrollTimerItem>
<ZScrollTimerItem>
<div class="item">Item 2</div>
</ZScrollTimerItem>
</ZScrollTimerVue3>
</template>
<script setup>
import { ZScrollTimerVue3, ZScrollTimerItem } from 'zzy-scroll-timer/vue3';
</script>
Vue 2
<template>
<ZScrollTimerVue2 :interval="2000" direction="up">
<ZScrollTimerItem>
<div class="item">Item 1</div>
</ZScrollTimerItem>
<ZScrollTimerItem>
<div class="item">Item 2</div>
</ZScrollTimerItem>
</ZScrollTimerVue2>
</template>
<script>
import { ZScrollTimerVue2, ZScrollTimerItem } from 'zzy-scroll-timer/vue2';
export default {
components: { ZScrollTimerVue2, ZScrollTimerItem }
}
</script>
React
import { ZScrollTimerReact, ZScrollTimerItem } from 'zzy-scroll-timer/react';
function App() {
return (
<ZScrollTimerReact interval={2000} direction="up">
<ZScrollTimerItem>
<div className="item">Item 1</div>
</ZScrollTimerItem>
<ZScrollTimerItem>
<div className="item">Item 2</div>
</ZScrollTimerItem>
</ZScrollTimerReact>
);
}
参数说明
| 参数 | 类型 | 说明 | 默认值 |
|---|---|---|---|
| interval | number | 滚动间隔时间(毫秒) | 2500 |
| direction | string | 滚动方向:'up' 或 'down' | 'up' |
| scrollPx | number | 每次滚动的像素数,默认使用子元素高度 | 0 |
| transitionDuration | number | 过渡动画持续时间(毫秒) | 300 |
| transitionTimingFunction | string | 过渡动画 timing 函数 | 'ease-in-out' |
| transition | string | 完整的过渡动画设置,优先级更高 | - |
| immediate | boolean | 是否立即开始第一次滚动 | false |
| autoStart | boolean | 是否自动开始滚动 | true |
方法说明
通过 ref 可以调用以下方法:
| 方法 | 说明 |
|---|---|
| start() | 开始滚动 |
| stop() | 停止滚动 |
各框架调用方式
Vue 2:
this.$refs.scrollTimerRef.start();
this.$refs.scrollTimerRef.stop();
Vue 3:
scrollTimerRef.value?.start();
scrollTimerRef.value?.stop();
React:
scrollTimerRef.current?.start();
scrollTimerRef.current?.stop();
动态更新
组件支持动态更新所有参数,当参数变化时会自动重新初始化:
- 方向变更:会重置滚动位置并保留原始子元素结构
- 其他参数变更:会销毁旧实例并创建新实例
<!-- 动态修改方向 -->
<ZScrollTimerVue3
:direction="currentDirection"
:interval="currentInterval"
/>
实现原理
zzy-scroll-timer 的核心实现思路:
- 克隆子元素:为了实现无缝滚动,会将第一个子元素克隆一份放到最后
-
定时器控制:使用
setTimeout控制滚动间隔 -
CSS 过渡:使用
transform和transition实现平滑滚动动画
核心代码结构:
class ScrollTimer {
private element: HTMLElement;
private options: ScrollTimerOptions;
private timer: number | null = null;
constructor(element: HTMLElement, options: ScrollTimerOptions) {
this.element = element;
this.options = options;
this.init();
}
private init() {
// 克隆第一个子元素实现无缝滚动
this.cloneFirstChild();
// 开始定时滚动
this.startTimer();
}
public start() {
this.startTimer();
}
public stop() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
}
应用场景
- 公告栏滚动:网站首页的公告通知滚动展示
- 新闻轮播:新闻列表的自动滚动展示
- 数据展示:实时数据的滚动展示
- 排行榜:用户排行榜的滚动展示
- 消息通知:系统消息的滚动提示
项目地址
- Gitee: zzy-scroll-timer
- NPM: zzy-scroll-timer
总结
zzy-scroll-timer 是一个简单实用的滚动定时器插件,它的优势在于:
- 跨框架支持,一套代码适配 Vue 2/3 和 React
- API 设计简洁,上手容易
- 功能完善,支持自动/手动控制和动态更新
- TypeScript 支持,类型安全
如果你正在寻找一个轻量级的滚动组件,不妨试试 zzy-scroll-timer!
如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 🎉
拆完 Upwork 前端我沉默了:你天天卷的那些技术,人家根本没用
深度逆向分析:Upwork “Best Matches” 页面的前端架构、数据流与投标决策模型
声明:本文仅用于前端架构学习与技术交流目的。所有涉及个人隐私的数据(Token、IP、UID、具体职位信息等)均已脱敏或替换为虚构数据。本文不提供任何可直接用于自动化爬取的完整代码,也不鼓励违反任何平台服务条款的行为。
一、背景与动机
作为全球最大的自由职业平台,Upwork 每年促成数十亿美元的交易。对于活跃在平台上的 Freelancer 而言,“Best Matches”(最佳匹配)页面是每天打开频率最高的入口——它决定了你看到哪些职位、在什么时间点投递 Proposal、以及花费多少 Connects。
但你有没有想过:这个页面背后的技术架构是什么样的?数据是如何流转的?客户画像字段是怎样组织的?如果能深入理解这些,是否可以构建一套更科学的"投标决策模型"?
本文将从一次完整的网络请求抓包出发,逐层剥开 Upwork Best Matches 页面的前端架构、API 数据流、Vuex 状态管理,以及客户画像(Client Profile)和职位质量(Posting Quality)的完整数据结构。最终,我们会基于这些发现设计一套 Connect 风险评估框架。
需要强调的是:本文所有分析均基于浏览器 DevTools 中公开可见的前端代码和网络请求,属于对已发布软件的观察性研究,不涉及任何绕过安全机制或未授权访问的行为。文中出现的所有数值、ID、职位描述均为虚构或已做偏移处理。
二、技术栈全景:一个典型的 Nuxt 2 微前端架构
2.1 整体架构概览
通过对 Best Matches 页面加载过程的网络请求分析(约 200+ 请求),可以还原出以下技术栈:
前端框架层使用 Vue.js 2.7.x(Runtime-only build)、Vuex 3.6.x(状态管理)、Vue I18n 8.x(国际化),上层是 Nuxt 2.x(SSR 框架),构建工具为 Webpack,PWA 支持由 Workbox 提供,实时通信基于 Atmosphere(WebSocket 库)。
这个技术选型值得注意:截至 2026 年初,Vue 2 已于 2023 年底进入 EOL(End of Life)状态,Nuxt 2 同样不再维护。Upwork 仍在使用这套技术栈,说明其前端现代化迁移是一个长期过程——这与 Upwork 工程博客早在 2017 年就发表微前端现代化文章形成了有趣的时间对比。从 Perl 单体到微服务再到微前端,大型平台的技术演进往往比外界想象的慢得多。
2.2 代码分块策略
Webpack 的 code-splitting 被用到了极致。核心入口文件包括 Webpack 运行时、公共依赖库 bundle、Nuxt 主入口(含路由注册)、以及 Best Matches 页面组件 chunk。
业务组件全部采用懒加载,从文件命名可以推断出组件边界。例如职位卡片组件(JobTile)、分类编辑弹窗(CategoriesEditModal)、已保存职位弹窗(SavedJobsModal)、自由职业者侧栏模块系列(FreelancerSidebar)、企业职位额外信息组件(EnterpriseJobAdditionalInfo)等。CSS 拆分同样细致,通常以 styles~组件名 的形式命名。
整个 Find-Work 应用包含超过 60 个 JS/CSS chunk。这种激进的代码拆分策略在首屏加载时产生了大量小文件请求——这是一个典型的"过度拆分"案例,虽然有利于按需加载,但在 HTTP/1.1 环境下会增加连接开销。当然,如果配合 HTTP/2 的多路复用,这个问题会得到缓解。
2.3 微前端导航栏
最有意思的架构发现之一:顶部导航栏是一个独立部署的微前端应用,拥有自己的 Webpack 构建产物(manifest、chunk-vendors、main 三个核心文件)和独立的 Vuex Store。
通过在控制台遍历所有 Vue 实例,可以发现页面上实际存在两个完全独立的 Vuex Store。
导航栏的 Store 包含约 18 个模块,涵盖 tracing、context、user、visitor、organizations、flags、geo、navigation、notifications 等。
主应用的 Store(通过 window.$nuxt.$store 访问)包含约 24 个模块,涵盖 tracker、job、jobDetails、tracking、context、user、visitor、theme、flags、saveJob、flagJob、forter 等。
两个 Store 之间存在大量重复模块(如 user、context、organizations),这是微前端架构"自治 vs 冗余"的经典权衡。
这印证了 Upwork 工程团队在 2017 年博客中描述的微前端演进路径:导航栏最初依赖 PHP 单体应用的一个端点返回 HTML(被原文作者自称为"hack"),后来被重构为独立的 Navigation Service 返回 JSON 结构由前端库渲染。如今它已演进为一个完全独立的 Nuxt 微前端应用,拥有自己的构建流水线、部署周期和状态管理。
2.4 认证与微前端通信
微前端之间通过 OAuth2 从属令牌(Subordinate Token)实现认证共享。主应用通过认证服务获取从属令牌后,通过全局 fetch 拦截器注入到每个微前端的请求头中。这套机制确保了用户在不同微前端之间的无缝切换体验。
三、第三方追踪生态:令人惊讶的追踪密度
3.1 广告与分析平台
在 200+ 请求中,超过 55 个属于第三方广告和分析追踪。已识别的平台类型包括:
搜索广告类有 Google Ads(多个转化跟踪 ID)和 Bing UET。社交广告类涵盖 Facebook Pixel、LinkedIn Insight Tag、TikTok Pixel、Twitter/X Ads Pixel、Reddit Pixel。分析类有 Google Tag Manager 和 GA4。效果归因类包括 DoubleClick/Campaign Manager、Impact Radius、Podscribe、iSpot.tv、Spotify Pixel、Quora Pixel。用户调研类使用 Qualtrics 的站内拦截问卷。
这意味着用户在 Upwork 上的每一次页面浏览、搜索、职位点击,都会同时向超过 15 个广告平台发送事件数据。对于关注隐私的用户来说,这个密度值得注意。
3.2 安全与反欺诈服务
安全层面同样布局密集:反欺诈风控服务用于识别可疑交易行为,基于位置的身份验证服务用于检测异常登录地点,无效流量检测服务用于过滤机器人流量,隐私合规服务用于管理 Cookie 同意(符合 GDPR 等法规要求)。
3.3 设备指纹采集
多套指纹系统采集用户设备信息,维度包括浏览器类型与版本、屏幕分辨率、GPU 型号(通过 WebGL 渲染器字符串)、操作系统、语言偏好、时区、已安装字体列表、浏览器插件、存储能力、权限状态、Canvas 指纹、WebGL 指纹、音频指纹等。
从技术角度看,这种"多重指纹"策略虽然增强了安全性和反欺诈能力,但也带来了显著的性能开销——每次页面加载需要初始化多个 SDK、执行多轮指纹采集计算。这是安全需求与性能优化之间的永恒权衡。
3.4 请求分布概览
对全部约 200+ 请求按类型分类,大致分布为:Upwork 自有 JS 文件约 65 个,CSS 文件约 30 个,API 请求约 15 个,广告与追踪请求超过 55 个,安全与反欺诈请求超过 20 个,其余为字体、图片等静态资源。追踪与安全类请求合计占总请求数的约 37%,这个比例在业界属于偏高水平。
四、数据流深度剖析:职位数据从哪里来?
这是本文最核心的部分。理解数据流,才能知道页面是如何工作的。
4.1 首屏数据:SSR 内嵌的 window.__NUXT__
Best Matches 页面采用 Nuxt 的 SSR(Server-Side Rendering)模式。服务端渲染时,首屏所需的数据被直接序列化到 HTML 中的 window.__NUXT__ 对象。
这个对象的顶层结构为:
window.__NUXT__ = {
data: {...}, // 页面级异步数据
state: {...}, // 初始 state(含 i18n 等)
once: [...], // 仅执行一次的标记
_errors: {...}, // 错误信息
config: {...}, // 运行时配置
serverRendered: true, // SSR 标记
path: "/nx/find-work/best-matches", // 当前路由
vuex: {...} // Vuex store 初始状态(24 个模块)
}
关键发现:首屏约 30 条职位数据并非通过客户端 GraphQL 请求获取,而是在 SSR 阶段直接嵌入 HTML。 这意味着在 Network 面板中,你不会看到一个明确的"获取职位列表"的 API 请求——数据已经在页面 HTML 中了。整个 __NUXT__ 对象大约 33KB。
4.2 客户端 GraphQL 请求:全部是辅助数据
页面加载完成后,客户端发起约 13 个 GraphQL 请求(POST /api/graphql/v1),但没有一个是获取职位列表的。通过 URL 中的 alias 参数可以识别每个请求的用途:
身份验证状态检查(idvStatus)、用户资料获取(profile.retrieve)、用户补充信息(profile.additionalInfo)、已保存职位数量(savedJobsCount)、账户健康状态(account-health-status)、赞助广告参数(sponsored-ad-targeting)、功能开关(feature flags)、目录购买资格(catalog-purchase-eligibility)、合同转雇佣相关信息(C2H 系列)、组织功能加载(loadOrgMNYFeature)。
此外还有一个 REST 请求用于获取未读通知数,以及一个 WebSocket 连接用于实时消息推送。
这个发现对于想要理解或复现页面功能的人至关重要:如果你只关注网络面板中的 XHR/Fetch 请求,你永远找不到职位列表数据,因为它根本不是通过客户端请求获取的。
4.3 运行时数据的存储位置
既然 SSR 数据在 hydration 后会被 Vue 接管,那么运行时的职位列表数据存储在哪里?
通过遍历页面所有 Vue 实例并检查其 computed properties,最终在一个组件的 computed property uniqueJobs 中找到了完整的职位列表数据(约 30 条,总计约 103KB)。
这是一个值得注意的架构决策:职位列表数据不在 Vuex Store 中,而是在页面级组件的 computed property 中。 Vuex Store 中的 job 模块仅存储了 visitedJobs(用户浏览过的职位 UID 与时间戳数组),作为历史记录使用,而非职位列表本身。
这样设计的好处是:路由切换时组件销毁,列表数据自动被垃圾回收,避免了大量职位数据在 Store 中累积导致内存问题。
4.4 详情页的数据加载
当用户点击某个职位进入详情视图时,实际路由为 /best-matches/details/:jobId,这是一个 SPA 路由切换,不会触发完整的页面重载。Find-Work 的完整路由结构为:/nx/find-work/best-matches、/most-recent、/saved-jobs,每个下面都有 details/:jobId 子路由。
详情页的完整数据存储在 Vuex Store 的 jobDetails 模块中,包含 35 个顶层字段。如果该职位的基础数据已在列表响应中预加载,则可能不会发起额外的 GraphQL 请求;否则会发起 jobPosting(id) 查询获取完整数据。
4.5 滚动加载与翻页
当用户在列表页向下滚动时,会触发新的 GraphQL 请求获取更多职位。此时使用的 query 是 marketplaceJobPostings,核心参数包括 searchType(如 USER_JOBS_SEARCH)和 sortAttributes(排序方式,如按新鲜度或最佳匹配度排序)。
这也是唯一能在 Network 面板中直接观察到的职位数据请求——首屏之后的增量加载。
五、完整数据结构:列表页 vs 详情页
理解两个页面分别提供哪些字段,是构建评估模型的基础。
5.1 列表页单条职位数据结构(JobListItem)
每条职位约 3KB,包含以下字段群:
基础信息:职位唯一 ID(uid)、加密 ID(ciphertext,用于构造 URL)、记录编号(recno)、标题(title)、描述(description,可能被截断)、类型(type,1=固定价格,2=按小时计费)、状态(status)。
推荐与排序:在列表中的位置(position)、相关性编码分数(relevanceEncoded)、原始索引(originalIndex)。这些字段揭示了平台推荐算法的输出结果。
预算信息:固定预算金额与货币(budget.amount + currencyCode)、小时预算范围(hourlyBudgetMin/Max)、周预算(weeklyBudget)。
时间与工期:创建时间(createdOn)、发布时间(publishedOn)、续发时间(renewedOn)、持续时间标签(duration)、参与度描述(engagement)、需要招聘的人数(freelancersToHire)。
竞争信息:投递所需 Connects 数(connectPrice)、提案数量级别(proposalsTier,如"5 to 10"、"10 to 15"等文本描述,而非精确数字)。
技能与分类:技能数组(skills,每个元素含 name 和 prettyName)、职业分类(occupations)。
客户摘要:嵌套在 client 对象中,这是列表页能拿到的客户画像数据。包含总雇佣次数(totalHires)、总花费金额与货币(totalSpent)、支付验证状态(paymentVerificationStatus)、地区信息(location,精确到国家)、评价数量(totalReviews)、平均评分(totalFeedback)、是否隐藏财务信息(hasFinancialPrivacy)。
标记字段:是否高级推广(premium)、是否企业职位(enterpriseJob)、当前用户是否已投递(isApplied)。
5.2 详情页数据结构(JobDetails)
详情页通过 Vuex 的 jobDetails 模块提供更丰富的信息,35 个顶层字段中最重要的有以下几类:
job 对象在列表页字段基础上增加了:分类与分类组(category/categoryGroup)、要求的经验级别(contractorTier,1=入门,2=中级,3=专家)、工期详情(engagementDuration,含文本标签和周数)、客户活动指标(clientActivity,含精确的申请人数、已雇佣人数、已邀请面试人数、已发出邀请数、客户最后活跃时间)、客户筛选条件(qualifications,如国家限制、语言要求、JSS 最低分要求等)、筛选问题(questions)、附件(attachments)、续发标记(wasRenewed)、高级推广标记(isPremium)、发布设备与浏览器信息(annotations)、AI 面试相关字段(hasAiInterview、aiInterviewStatus 等——这是近期新增的功能)。
buyer 对象是客户画像的核心,包含 7 个子字段:是否企业客户(isEnterprise)、支付方式是否已验证(isPaymentMethodVerified)、统计数据(stats,含总合同数、活跃合同数、累计工时、评价数量、综合评分、有雇佣记录的职位数、总花费金额与货币)、地区(location,含城市和国家)、公司信息(company,含注册时间)、发帖统计(jobs,含总发帖数和当前开放数)、平均时薪(avgHourlyJobsRate)。
connects 对象包含每 Connect 价格、竞价价格、投递所需数量、当前可用数量、是否可查看其他申请者费率。
applicants 对象包含竞争者的平均报价、最低报价、最高报价——这在列表页是完全看不到的,是精细化决策的关键数据。
currentUserInfo 对象包含当前用户的时薪和资质匹配结果列表(qualificationsMatches),展示客户设置的每条筛选条件以及当前用户是否满足。
workHistory 数组包含该客户的历史合同记录,每条含总花费、累计工时、费率、双方互评(评分和评语)、职位信息(标题和类型)。
5.3 字段对比:精度上限的差异
列表页拥有但详情页结构不同的字段有:推荐相关性分数(relevanceEncoded)、在列表中的排名位置(position)、竞争级别文本描述(proposalsTier)。
仅在详情页出现的关键决策字段有:精确的客户总花费(buyer.stats.totalCharges)、客户注册日期(buyer.company.contractDate)、总发帖数(buyer.jobs.postedCount,用于计算雇佣率)、平均时薪(buyer.avgHourlyJobsRate)、精确申请人数(clientActivity.totalApplicants)、已邀请面试人数(clientActivity.totalInvitedToInterview)、竞争者报价范围(applicants 的 avg/min/max)、历史合同详情(workHistory)、客户筛选条件与匹配度(qualifications + qualificationsMatches)。
核心结论:列表页的 client 摘要足够做第一轮粗筛(排除明显不值得的职位),但精确的投标决策必须依赖详情页的 buyer 和 applicants 数据。
六、基于数据结构的 Connect 风险评估模型
有了完整的数据结构,我们可以设计一套量化的评分体系,帮助 Freelancer 科学地分配有限的 Connects。
6.1 客户画像评分(Client Score)
以下评分标准基于公开的 Upwork 社区经验总结和平台官方的红旗指南,结合本文发现的具体字段设计。所有示例数值均为虚构。
支付验证(权重最高) :isPaymentMethodVerified 是最重要的风控信号。已验证得 +25 分,未验证直接 -50 分。Upwork 官方的安全指南也将"未验证支付方式"列为首要红旗之一。
历史花费(totalCharges.amount)反映客户的付费意愿和能力。10K-1K-1-0 得 -10。$0 花费的客户要么是新客户,要么是从未完成过付款的客户,两种情况都需要额外警惕。
雇佣率(totalJobsWithHires / postedCount)反映客户是否真正在招人还是只是"逛逛"。超过 70% 得 +15,50%-70% 得 +10,30%-50% 得 +5,低于 30% 得 -5。需要注意的是,这个指标只有在详情页才能精确计算(列表页缺少 postedCount)。
综合评分(score)是平台对客户的综合评价。4.5 以上得 +15,4.0-4.5 得 +10,3.0-4.0 得 +5,低于 3.0 得 -10,无评分得 -5。
账号年龄(基于 company.contractDate 与当前日期的差值):超过 3 年得 +10,1-3 年得 +5,3 个月-1 年得 0,不到 3 个月得 -5。新注册就发帖的客户可能是真实需求,也可能是一次性账号。
评价数量(feedbackCount):超过 10 条得 +10,5-10 条得 +5,1-4 条得 +3,0 条得 -5。
举例说明(虚构数据):假设一位英国客户,支付已验证(+25),总花费 $22,000+(+20),雇佣率约 60%(+10),综合评分 4.5+(+15),账号超过 10 年(+10),十余条评价(+10)。客户评分约 90/100,属于优质客户。但这只是客户侧的评估,还需要结合职位本身的质量。
6.2 职位质量评分(Posting Score)
描述长度与质量:超过 1000 字符得 +10,500-1000 得 +5,200-500 得 0,少于 200 得 -10。过短的描述通常意味着需求不明确,后续沟通成本高。是否包含具体的技术要求、交付标准、时间线也是重要的质量信号。
预算明确性与合理性:有明确预算得 +10,标注"Not Sure"得 0,隐藏预算得 -5。更关键的是预算与经验级别的匹配:如果客户要求 Expert 级别(contractorTier=3)但预算低于 $200,这是一个明显的红旗(-15),说明客户对市场价格缺乏认知,或者有意压价。
Connect 价格(connectPrice)间接反映了平台对该职位竞争度的评估:2-4 Connects 是低竞争(+10),6-10 是中等(+5),12-16 是较高竞争(0),超过 16 是极高竞争(-5)。Connect 价格越高,你的每次投标成本越大,需要更高的中标概率才能回本。
竞争态势:精确申请人数低于 5 是好机会(+10),5-15 是正常(+5),15-30 需谨慎(0),超过 30 是红海(-10)。如果 totalInvitedToInterview > 0,意味着客户已经有意向候选人(-10),你的竞争难度显著增加。如果 totalHired > 0 但 freelancersToHire 大于已雇佣人数,可能还有机会。
新鲜度:发布 1 小时内得 +10(早鸟优势明显),1-6 小时得 +5,6-24 小时得 0,超过 24 小时得 -5。wasRenewed=true 表示职位被重新发布,说明上一轮没有找到合适人选,既是机会(竞争者被淘汰)也是警示(可能是需求本身有问题),给 +3 分。
举例说明(虚构数据):假设一个数据处理类固定价格职位,描述约 900 字符(+5),预算 150 预算(-15),Connect 价格在 12-16 区间(0),约 10 个申请者(+5),0 个面试邀请(+5),发布约 5 小时(+5),已续发(+3)。职位评分约 18/100,预算与要求严重不匹配,需要谨慎评估是否值得花费 Connects。
6.3 综合决策
将客户评分和职位评分结合,一种简单的加权方式为:finalScore = clientScore × 0.4 + postingScore × 0.4 + matchScore × 0.2。其中 matchScore 来自详情页 currentUserInfo.qualificationsMatches 中满足条件的比例。
决策参考:总分 70 以上可以优先投标,50-69 值得考虑,30-49 需谨慎评估投入产出比,低于 30 通常不建议投标。
还可以计算简单的 ROI 指标:ROI = (预算金额 × 估算中标概率) / (connectPrice × 单个 Connect 的美元价值)。当 ROI 低于某个阈值时,即使客户和职位看起来不错,纯经济计算也不划算。单个 Connect 的美元价值可按当前 Connects 购买价格计算。
6.4 实操中的快速决策流程
在日常使用中,不需要精确计算分数,可以按以下优先级快速过滤:
第一关,看支付验证。未验证直接跳过,除非你有特殊理由(如职位描述非常匹配且预算很高)。
第二关,看预算与 Connect 成本的比值。如果预算除以所需 Connects 数低于某个阈值(例如 $10),ROI 大概率不划算。
第三关,看客户历史花费。$0 花费且无评价的新客户需要额外谨慎,但也不必一概而论——每个优质客户都曾是"零花费"的新客户。
第四关,看竞争态势。如果已有人被邀请面试,除非你的竞争力非常强或有差异化优势,否则机会不大。
第五关,看竞争者报价(仅详情页可见)。如果平均报价远高于客户预算,说明市场认为预算过低。你可以据此决定是按预算投还是提出更合理的报价。
七、架构启示与技术思考
7.1 SSR + Hydration 的数据流设计
Upwork 选择在 SSR 阶段直接注入首屏数据而非让客户端请求,这是经典的 Nuxt 2 asyncData / fetch 模式。好处是首屏渲染快、SEO 友好、减少了客户端的 API 请求数量;缺点是 HTML 体积增大(__NUXT__ 对象约 33KB),且同一份数据在服务端渲染和客户端 hydration 时各处理一次。
更有趣的是数据的运行时归宿:职位列表最终存储在组件的 computed property 而非 Vuex Store 中。这与直觉相悖——通常我们会将核心业务数据放入全局 Store。但仔细想想,这个设计是合理的:Best Matches 列表是"一次性消费"的数据,用户看完就走,没有必要持久化在全局状态中增加内存负担。Vuex 中只保留了 visitedJobs 这类需要跨组件共享的轻量元数据。
7.2 微前端的成本与收益
导航栏作为独立微前端部署实现了团队解耦——导航团队可以独立开发、测试、部署,不影响主应用的发布节奏。但成本也是实实在在的:额外的 Webpack 运行时开销、两个 Store 中大量重复的模块(user、context、organizations 等)、OAuth 子令牌机制增加了认证架构的复杂度。
Upwork 工程博客中提到的 Consul + Nginx 自动化路由方案也值得关注:每个微前端在 Consul 注册自己服务的路径,Consul Template 自动生成 Nginx 配置。这避免了手动修改负载均衡器配置的风险,也支持了蓝绿部署和金丝雀发布等高级部署模式。
7.3 追踪密度的性能影响
超过 55 个追踪请求占总请求数的约 27%。虽然大多数使用图片像素(<img> 标签)或 navigator.sendBeacon 不阻塞主线程,但多个第三方 SDK 的初始化(GTM 容器加载、各社交平台的 SDK 初始化)仍会占用可观的 JavaScript 解析和执行时间。
这是一个典型的商业需求(精确的广告归因、多渠道用户行为分析)与技术性能之间的矛盾。作为一个以 GMV 和用户增长为核心指标的平台,Upwork 显然选择了优先满足商业需求。
7.4 技术债务:Vue 2 EOL
Upwork 仍在使用已停止维护的 Vue 2 + Nuxt 2 组合。考虑到其复杂的微前端架构、超过 60 个 chunk 的组件拆分、深度定制的 Webpack 配置、以及多个微前端之间的通信机制,迁移到 Vue 3 + Nuxt 3 将是一个需要数年的巨大工程。
从 WebSocket 库的选择也能看出技术栈的"年龄"——Atmosphere 在 Java 后端社区中有一定历史,但在现代前端开发中并不常见,大多数项目会选择 Socket.io 或原生 WebSocket API。
这并非批评 Upwork 的技术决策。对于一个每年处理数十亿美元交易的平台而言,"稳定运行"远比"使用最新技术"重要。正如他们自己在工程博客中所写:“Modernization is not a project but a process.”
八、总结
本文通过对 Upwork Best Matches 页面的前端架构逆向分析,得出了以下发现:
在架构层面,Upwork 采用 Nuxt 2 SSR + 微前端架构。导航栏是一个独立部署的 Nuxt 微前端应用,拥有自己的 Webpack 构建产物和 Vuex Store。主应用使用激进的代码拆分策略,超过 60 个 JS/CSS chunk。两个 Vuex Store 分别服务导航和主应用,存在模块重复。
在数据流层面,首屏职位列表数据通过 SSR 嵌入 window.__NUXT__,不通过客户端 API 请求获取。运行时数据存储在组件 computed property 而非 Vuex Store。客户端发起的约 13 个 GraphQL 请求全部是辅助性的(用户资料、保存状态、功能开关等)。详情页数据存储在 Vuex jobDetails 模块中。滚动加载时才会发起 marketplaceJobPostings GraphQL 请求。
在数据结构层面,列表页单条职位含约 20 个字段(含客户摘要),详情页扩展到 35 个顶层字段(含完整客户画像、竞争者报价范围、历史合同、筛选条件匹配度)。两者的字段差异直接决定了评估的精度上限——粗筛用列表页数据,精筛必须进详情页。
在实际应用层面,基于这些字段可以构建"客户画像评分 + 职位质量评分"的量化模型,核心决策因子包括支付验证状态、历史花费、雇佣率、预算合理性、竞争态势、Connect 成本等。
技术逆向分析的价值不仅在于"知道别人怎么做的",更在于理解系统的数据结构和业务逻辑后,能够做出更理性的决策。无论你是对前端架构感兴趣的开发者,还是在 Upwork 上打拼的 Freelancer,希望这篇分析都能带来一些启发。
参考资料
- Upwork Engineering Blog: Modernizing Upwork with Micro Frontends (2017) — www.upwork.com/careers/eng…
- Upwork Engineering Blog: Upwork Modernization: An Overview (2017) — www.upwork.com/careers/eng…
- Upwork Developer Portal & GraphQL API Documentation — www.upwork.com/developer
- Upwork Resources: Client Red Flags — www.upwork.com/resources/c…
- Upwork Resources: Spotting Fake Job Posts — www.upwork.com/resources/s…
- Vue.js 2 End of Life Announcement — v2.vuejs.org/eol/
这是完整的脱敏版本。以下是与之前版本的核心差异清单,供你核对:
已替换/移除的敏感信息:所有真实职位标题替换为泛化描述(如"一个数据处理类固定价格职位");所有客户画像精确数值替换为模糊范围(如"$22,000+“、“约 60%”、“4.5+”、“超过 10 年”、“十余条”);所有具体城市替换为"英国某城市"或"英国客户”;所有职位 UID/ciphertext 已移除;所有竞争者报价精确数值替换为描述性语言;所有 JavaScript 文件的完整 hash 已移除或简化;所有第三方追踪的具体 ID(Pixel ID、Ads ID、GA Measurement ID 等)已移除;所有 OAuth token、IP 地址、设备指纹 ID 已移除;visitedJobs 浏览历史完全未引用。
保留的技术价值:架构分析完整保留;数据流路径完整保留;字段结构名称完整保留(这些是代码中的通用命名,不含个人信息);评分模型逻辑完整保留;工程思考完整保留。
从入门到进阶:手写React自定义Hooks,让你的组件更简洁
从入门到进阶:手写React自定义Hooks,让你的组件更简洁
大家好,今天我们来聊聊React中非常实用的自定义Hooks。通过两个实际例子(鼠标位置追踪和Todo待办应用),带你从零开始封装自己的Hooks,彻底理解“逻辑复用”的魅力,并掌握如何避免常见的内存泄漏问题。
什么是Hooks?
Hooks是React 16.8引入的一种函数式编程思想,它让我们在函数组件中使用状态和生命周期等特性。React内置了useState、useEffect等基础Hooks,而自定义Hooks则是将组件逻辑提取到可复用的函数中,以use开头,内部可以调用其他Hooks。
自定义Hooks的好处:
- 复用状态逻辑,避免重复代码
- 让UI组件更纯粹,只关注渲染
- 便于团队维护和共享核心逻辑
第一部分:不使用自定义Hooks实现鼠标追踪(并理解内存泄漏)
我们先从一个简单的需求开始:实时显示鼠标在页面上的位置。直接在App组件里写逻辑。
直接在组件内实现
新建App.jsx,代码如下:
import { useState, useEffect } from 'react';
function MouseMove() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
// 鼠标移动时的更新函数
const updateMouse = (e) => {
setX(e.clientX);
setY(e.clientY);
};
// 监听 mousemove 事件
window.addEventListener('mousemove', updateMouse);
console.log('添加事件监听');
// 清理函数:组件卸载时移除监听,防止内存泄漏
return () => {
window.removeEventListener('mousemove', updateMouse);
console.log('移除事件监听');
};
}, []); // 空依赖数组,只在挂载时执行一次
return (
<div>
鼠标位置:{x} , {y}
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
return (
<>
{count}
<button onClick={() => setCount(count => count + 1)}>
// 点击重新挂载函数
增加
</button>
{count % 2 === 0 && <MouseMove />}
</>
);
}
代码解析:
-
useState用来存储鼠标坐标,初始值为0。 -
useEffect接受两个参数:第一个是副作用函数,第二个是依赖数组。这里依赖数组为[],表示副作用只在组件挂载时执行一次,不会在每次渲染后重复执行。 - 在副作用函数中,我们定义了
updateMouse,并通过addEventListener注册了mousemove事件。 -
重点:
useEffect可以返回一个清理函数,React 会在组件卸载时调用它。我们在清理函数中移除了事件监听,这是避免内存泄漏的关键。
⚠ 如果不清理会发生什么?
想象一下:如果组件卸载时没有移除 mousemove 监听,那么 updateMouse 函数仍然存在于内存中,并且每次鼠标移动都会尝试调用 setX 和 setY。但此时组件已经被销毁,这些 setState 调用是无意义的,并且会导致内存泄漏——事件处理函数持有对组件作用域的引用,垃圾回收无法释放相关内存。长时间运行的应用可能会因此变得卡顿甚至崩溃。
验证方法:注释掉 return () => {...} 这一部分,然后反复点击按钮让 MouseMove 组件挂载/卸载,观察控制台。你会发现即使组件卸载了,鼠标移动时控制台依然打印“添加事件监听”(实际上并没有重新添加,但之前添加的监听还在),说明事件处理函数依然存活。这就是内存泄漏的表现。
没清理的效果图
我们看到就算函数已经卸载,事件依然会执行
![]()
清理后的效果图
可以看到,函数卸载后,事件不会执行了
![]()
第二部分:提取自定义Hook useMouse
我们把鼠标追踪的逻辑封装成一个独立的Hook,放在 hooks/useMouse.js 中。
创建 useMouse.js
import { useState, useEffect } from 'react';
/**
* 自定义 Hook:追踪鼠标在页面上的位置
* @returns {{ x: number, y: number }} 包含当前鼠标坐标的对象
*/
export const useMouse = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
/** @param {MouseEvent} e 原生鼠标事件对象 */
const update = (e) => {
setX(e.clientX);
setY(e.clientY);
};
window.addEventListener('mousemove', update);
console.log('useMouse: 添加监听');
// 清理函数:组件卸载时移除监听
return () => {
window.removeEventListener('mousemove', update);
console.log('useMouse: 移除监听');
};
}, []); // 依赖数组为空,保证只在挂载时执行一次
return { x, y };
};
API 详细解释:
-
useMouse是一个自定义 Hook,它内部使用了 React 的useState和useEffect。 -
返回值:一个包含
x和y的对象,类型均为number,表示当前鼠标坐标。 -
副作用:在组件挂载时添加
mousemove监听,卸载时移除。这里的清理逻辑与前面相同,但被封装在 Hook 内部,任何使用useMouse的组件都会自动获得正确的生命周期管理,无需重复编写清理代码。
在组件中使用 useMouse
现在改造 App.jsx,引入 useMouse:
import { useState } from 'react';
import { useMouse } from './hooks/useMouse';
function MouseMove() {
const { x, y } = useMouse(); // 一行代码搞定!
return (
<div>
鼠标位置:{x} , {y}
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>
点击重新挂载 MouseMove 组件
</button>
{count % 2 === 0 && <MouseMove />}
</>
);
}
现在 MouseMove 组件变得非常简洁,只负责渲染,逻辑全在 useMouse 中。如果其他地方也需要鼠标位置,直接调用 useMouse 即可,真正做到了“一次封装,多处复用”。
第三部分:使用自定义Hooks实现Todo应用(带本地存储)
接下来,我们实现一个更复杂的例子:带本地存储的Todo待办应用。我们将创建一个 useTodos Hook,封装所有todos的状态管理和持久化。
1. 编写 useTodos.js
这个Hook负责:
- 管理todos数组(增、删、改完成状态)
- 自动同步到localStorage,实现数据持久化
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos'; // 本地存储的键名,统一管理便于维护
/**
* 从 localStorage 加载待办数据
* @returns {Array} 存储的待办数组,如果没有则返回空数组
*/
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
/**
* 将待办数据保存到 localStorage
* @param {Array} todos - 待办数组
*/
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
/**
* 自定义 Hook:管理待办事项的所有逻辑(增删改、本地存储同步)
* @returns {{
* todos: Array,
* addTodo: (text: string) => void,
* toggleTodo: (id: number|string) => void,
* deleteTodo: (id: number|string) => void
* }}
*/
export const useTodos = () => {
// 使用函数形式初始化,避免每次渲染都重新读取 localStorage
const [todos, setTodos] = useState(loadFromStorage);
// 每当 todos 变化,自动保存到 localStorage
useEffect(() => {
saveToStorage(todos);
}, [todos]); // 依赖 todos,只有 todos 变化时才执行
/**
* 添加新待办
* @param {string} text - 待办内容
*/
const addTodo = (text) => {
setTodos([
...todos,
{
id: Date.now(), // 简单用时间戳作为临时唯一 ID(仅用于演示)
text,
completed: false
}
]);
};
/**
* 切换指定待办的完成状态
* @param {number|string} id - 待办项的 ID
*/
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // 切换状态,保持其他属性不变
: todo
)
);
};
/**
* 删除指定待办
* @param {number|string} id - 待办项的 ID
*/
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 返回所有状态和操作
return {
todos,
addTodo,
toggleTodo,
deleteTodo
};
};
API 详细解释:
-
loadFromStorage:内部辅助函数,用于读取本地存储,返回待办数组或空数组。 -
saveToStorage:内部辅助函数,将待办数组序列化后存入本地存储。 -
useTodos返回值:-
todos:当前待办列表,每个待办对象包含id(number/string)、text(string)、completed(boolean)。 -
addTodo(text):接收待办文本,创建一个新待办(id为当前时间戳,completed为false),并更新状态。 -
toggleTodo(id):接收待办id,遍历todos,找到对应项并反转其completed属性。 -
deleteTodo(id):接收待办id,过滤掉该项,更新状态。
-
关于内存泄漏的再次提醒:虽然本Hook中没有显式的事件监听或定时器,但 useEffect 依赖 [todos],会在每次 todos 变化后执行保存操作。这里没有清理函数的必要,因为保存操作是安全的。但如果我们在 useEffect 中启动了定时器或订阅了外部事件,就必须返回清理函数。
2. 编写UI组件(每个组件都带有详细API注释)
TodoInput.jsx - 输入框
import { useState } from 'react';
/**
* 待办输入表单组件
* @param {Object} props
* @param {Function} props.onAddTodo - 添加待办的回调函数,接收待办文本作为参数
*/
export default function TodoInput({ onAddTodo }) {
const [text, setText] = useState('');
/**
* 表单提交处理函数
* @param {Event} e - 表单提交事件对象
*/
const handleSubmit = (e) => {
e.preventDefault(); // 阻止页面刷新
const trimmedText = text.trim();
if (!trimmedText) return; // 输入为空时不添加
onAddTodo(trimmedText); // 调用父组件传递的添加函数
setText(''); // 清空输入框
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入待办事项..."
/>
</form>
);
}
TodoItem.jsx - 单个待办项
/**
* 单个待办项组件
* @param {Object} props
* @param {Object} props.todo - 待办对象 { id, text, completed }
* @param {Function} props.onDeleteTodo - 删除待办的回调,接收待办 id
* @param {Function} props.onToggleTodo - 切换完成状态的回调,接收待办 id
*/
export default function TodoItem({ todo, onDeleteTodo, onToggleTodo }) {
return (
<li className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggleTodo(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => onDeleteTodo(todo.id)}>删除</button>
</li>
);
}
TodoList.jsx - 待办列表
import TodoItem from './TodoItem';
/**
* 待办列表组件,渲染所有待办项
* @param {Object} props
* @param {Array} props.todos - 待办数组
* @param {Function} props.onDeleteTodo - 删除待办的回调
* @param {Function} props.onToggleTodo - 切换完成状态的回调
*/
export default function TodoList({ todos, onDeleteTodo, onToggleTodo }) {
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDeleteTodo={onDeleteTodo}
onToggleTodo={onToggleTodo}
/>
))}
</ul>
);
}
3. 在 App.jsx 中组装
import { useTodos } from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
export default function App() {
// 直接使用自定义Hook,获取所有状态和方法
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<>
<h1>Todo 待办清单</h1>
<TodoInput onAddTodo={addTodo} />
{todos.length > 0 ? (
<TodoList
todos={todos}
onDeleteTodo={deleteTodo}
onToggleTodo={toggleTodo}
/>
) : (
<div>暂无待办事项,添加一条吧~</div>
)}
</>
);
}
效果:
- 添加待办:输入文本,回车提交。
- 勾选复选框切换完成状态,文字样式变化(可自行添加CSS,比如加删除线)。
- 点击删除按钮移除该项。
- 刷新页面,数据依然存在,因为已经同步到localStorage。
效果图
![]()
第四部分:深入理解内存泄漏与清理的必要性
在React函数组件中,useEffect 是处理副作用的主要场所。常见的副作用包括:
- 订阅外部事件(如
mousemove、resize、WebSocket) - 设置定时器(
setInterval、setTimeout) - 手动修改DOM
- 数据请求(虽然请求本身不需要清理,但需要处理竞态)
所有这些副作用,如果在组件卸载后没有正确清理,都会导致内存泄漏。例如:
-
事件监听:组件卸载后,事件处理函数仍然被全局对象(如
window)引用,导致组件内部的状态变量和函数无法被垃圾回收。 -
定时器:即使组件卸载,定时器仍然会周期性触发回调,如果回调中使用了
setState,会报“在未挂载的组件上调用setState”的警告,并且造成内存泄漏。 - 订阅:类似于事件监听,必须取消订阅。
如何避免?
在 useEffect 中返回一个清理函数,React 会在组件卸载前和执行下一次副作用前调用它。这个清理函数应该:
- 移除事件监听
- 清除定时器
- 取消订阅
- 中止请求(如果支持)
示例:错误的写法(导致内存泄漏)
useEffect(() => {
const timer = setInterval(() => {
console.log('定时器执行');
}, 1000);
// 没有返回清理函数!
}, []);
组件卸载后,定时器依然运行,回调中的代码可能访问已经不存在的组件状态。
正确的写法
useEffect(() => {
const timer = setInterval(() => {
console.log('定时器执行');
}, 1000);
return () => clearInterval(timer); // 清理定时器
}, []);
在我们的 useMouse 例子中,我们正是通过返回清理函数来移除事件监听,确保了无论组件如何挂载/卸载,都不会留下残留的监听器。
总结
通过两个例子,我们见证了自定义Hooks的强大:
- useMouse:将副作用(事件监听)和状态封装起来,组件只需调用并渲染,同时自动处理了内存泄漏的清理逻辑。
- useTodos:不仅管理状态,还集成了本地存储持久化,让UI组件完全无感。
自定义Hooks让我们能够像搭积木一样组合逻辑,保持组件简洁,提升代码复用性。在实际项目中,你可以根据自己的业务封装更多通用Hooks,比如 useLocalStorage、useFetch、useWindowSize 等。
最后请记住:每当你在 useEffect 中引入持续性的副作用(事件、定时器、订阅),务必返回一个清理函数,这是React函数组件中防止内存泄漏的基本准则。
希望这篇文章能帮你打开自定义Hooks的大门,快去动手试试吧!