普通视图

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

ArcGIS Pro 中的 Python 入门

作者 GIS之路
2026年3月1日 11:08

^ 关注我,带你一起学GIS ^

前言

Python 脚本使自动化 ArcGIS Pro 中的工作流成为可能。

本教程来源于ESRI如何在ArcGIS Pro中学习使用Python。在本教程中,您将编写代码来确定工作空间中的所有要素类的要素数量。 这也介绍了Python语法的一些基础知识。 您将在ArcGIS ProPython窗口中编写代码。 可以将代码导出到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 ProPython窗口中运行,也可以在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开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集(全)

GDAL 开发合集(全)

GIS 影像数据源介绍

GeoJSON 数据源介绍

GIS 名词解释

ArcPy,一个基于 Python 的 GIS 开发库简介

GIS 开发库 Turf 介绍

GIS 开发库 GeoTools 介绍

GIS 开发库 GDAL 介绍

地图网站大全

从微信指数看当前GIS框架的趋势

Landsat 卫星数据介绍

OGC:开放地理空间联盟简介

中国地图 GeoJSON 数据集网站介绍

Vue3和Uniapp的爱恨情仇:小白也能懂的跨端秘籍

2026年3月1日 00:38

Vue3 与 UniApp 开发经验分享:跨端开发的选择与实践

最近不少刚接触前端的朋友问我,Vue3 和 UniApp 是不是竞争对手?

其实完全不是,我自己两个都在项目里用过,今天就从实际开发角度聊聊它们的区别、踩过的坑,以及怎么选。

先明确两者的定位

简单说:

  • Vue3 是一个纯 Web 前端框架,主要用来写浏览器里跑的 H5 页面、Web 应用等。
  • UniApp 是基于 Vue3 封装的跨端框架,它用 Vue3 的语法,但能把同一套代码编译到 H5、微信小程序、支付宝小程序、App、鸿蒙等多个平台。

举个实际例子:

如果你用 Vue3 写微信小程序,得额外用 Taro 这类框架做适配; 但用 UniApp 写,代码写完直接选平台打包就行,这是最直观的区别。

核心差别一:构建工具不一样

Vue3 的构建流程

Vue3 默认用 ViteWebpack,我一般用 Vite,创建项目很简单:

 # 创建 Vue3 项目
 npm create vite@latest my-vue-app -- --template vue
 cd my-vue-app
 npm install
 npm run dev

但如果你想把 Vue3 项目打包成 App,得额外加 CapacitorCordova,步骤会多一些:

 # 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 比较多,打包流程很直接:

  1. HBuilderX 里打开项目,点击顶部“发行”;
  2. 选你要打包的平台(比如“微信小程序”“App-云打包”);
  3. 填一下基本信息(比如 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 的生态

Vue3npm 包,生态非常丰富,比如做 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

作者 Panzer_Jack
2026年3月1日 00:34

原文链接:老船长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);
}

简要介绍一下核心概念:

  1. 属性(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),以免记录到不需要的后续渲染操作。

  1. 统一变量(Uniform):这是顶点着色器和片段着色器中的输入变量,用于接收全局数据,例如变换矩阵、光照参数等。统一变量在 JavaScript 中通过程序对象(Program Object)传递给 GPU。
// 这是一个统一变量,表示纹理采样器,用于在片段着色器中访问纹理数据
// 命名以 u_ 开头是一个常见的约定,表示这是一个统一变量
uniform sampler2D u_texture;
  1. 纹理(Texture):这是片段着色器中的输入变量,用于接收纹理数据,例如图片、视频等。纹理在 JavaScript 中通过纹理对象(Texture Object)传递给 GPU。(比如图片就是一种纹理)
// 通常 纹理的采样器会被声明为一个统一变量,类型为 sampler2D,表示这是一个二维纹理
uniform sampler2D u_texture;
  1. 内置变量(Built-in Variable):这是 GLSL 中预定义的变量,用于表示一些特殊的数据,例如 gl_Position(顶点位置)、gl_FragColor(片段颜色)等。这些变量在着色器中具有特殊的意义和用途。
// 这是一个内置变量,表示最终的顶点位置,必须在顶点着色器中赋值
gl_Position = a_position;
  1. 程序对象(Program Object):这是 WebGL 中用于管理着色器程序的对象,包含了顶点着色器和片段着色器的组合。程序对象在 JavaScript 中通过 WebGL API 创建和使用。

  2. 插值变量(Varying):这是顶点着色器和片段着色器之间的变量,用于在两者之间传递数据,例如颜色、纹理坐标等。插值变量在 JavaScript 中通过程序对象传递给 GPU。(比如常见的渐变色就是通过插值变量来实现的)

注意:在 WebGL 2.0 中,插值变量的显示声明已经被废弃,取而代之的是使用 out 关键字在顶点着色器中声明输出变量,并在片段着色器中使用 in 关键字声明输入变量来接收这些数据。

实现一个图片背景透明化小工具

GLSL 核心转换逻辑代码:

核心算法是:将 RGB 颜色空间转换为 YUV 颜色空间,在排除掉亮度(Y)分量的影响后,计算色度(UV)分量之间的距离来判断颜色是否接近于选取的颜色,从而实现选取色彩透明化的效果。

learning-webgal-1-img-1.png

  • 顶点着色器(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 中,图片的原点位于左上角,所以需要进行垂直翻转来确保纹理正确显示。 直接来看下面这张图👇

learning-webgal-1-img-2.png 我们可以看到在 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 的完整版在线地址

参考链接

  1. webgl2fundamentals.org/webgl/lesso…

  2. messiahhh.github.io/blog/docs/c…

简单高效的状态管理方案:Hox + ahooks

作者 fe小陈
2026年2月28日 23:54

在 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 状态管理的生态,并在实际项目中做出明智的技术决策。

当「多应用共享组件」成了刚需:我们从需求到模块联邦的落地小史

作者 兆子龙
2026年2月28日 22:35

当「多应用共享组件」成了刚需:我们从需求到模块联邦的落地小史

以真实项目需求为背景,讲我们如何从「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内存管理】那些你以为释放了,其实还在占着的资源

作者 叶智辽
2026年2月28日 22:24

前言

你以为你 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面板拍快照对比,发现罪魁祸首有三个:

  1. 每秒钟新建一个CanvasTexture,旧的虽然调了 dispose,但底层的 Canvas 对象还在内存里,因为Konva的 toCanvas() 每次都会生成新的Canvas,这些Canvas被 CanvasTexture 引用着,无法释放。
  2. Konva内部也有缓存,每次 draw 都会产生新的离屏Canvas,虽然我调用了 clear,但Konva为了性能,会保留一些内部对象,这些对象里又引用了画布。
  3. Sprite材质每次重新赋值 map,旧纹理即使dispose了,也可能会被GPU管线延迟释放,积累多了就爆了。

折腾了两周,我最终做了一个耻辱的决定:放弃Sprite方案,改用普通HTML div

就是那种最简单、最没技术含量的 position: absolute,通过CSS把div定位到画布上方,监听mousemoveintersect来更新位置。什么“天衣无缝”?去他的吧,不崩溃才是王道。

说来也怪,换了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);

geometriestextures 的数量如果只增不减,那就是泄漏了。


最后的忠告:写个 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 的边界

2026年2月28日 22:20

摘要:当大模型遇上工具调用,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 服务器:

  1. 远程 HTTP 服务(高德地图)
    通过 URL 直接连接官方提供的 MCP 服务,适用于标准化、高可用的公共 API。
  2. 本地命令行服务(文件系统)
    使用 npx 动态安装并运行 @modelcontextprotocol/server-filesystem,指定工作目录为当前项目路径。这种方式非常适合需要访问本地资源的场景。
  3. 浏览器自动化服务(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) 模式:

  1. Reason:模型根据当前对话历史生成下一步动作(可能是直接回答,也可能是调用工具)。
  2. Act:如果检测到 tool_calls,则逐个执行对应工具,并将结果以 ToolMessage 形式反馈给模型。
  3. 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展示,并且把那个页面标题改为酒店名
`)

这个看似简单的自然语言指令,背后涉及多个复杂步骤:

  1. 地理搜索:调用高德地图 MCP 服务,查询“北京南站附近酒店”。
  2. 数据提取:从返回结果中提取酒店名称、地址、图片 URL 等信息。
  3. 浏览器控制:启动 Chrome DevTools MCP,打开新标签页加载图片。
  4. DOM 操作:修改每个标签页的 <title> 元素为对应酒店名。
  5. 状态同步:将所有操作结果反馈给模型,形成闭环。

🤯 震撼之处:整个过程完全由大模型自主规划!开发者只需定义工具能力,无需编写任何业务流程代码。这就是 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%!

作者 Dilettante258
2026年2月28日 21:58

一个Node 后端项目的启动方式可以分类为三种:

  1. 由源代码直接启动,如tsx src/server.ts
  2. 由tsc简单转译,如tsc 编译后 node dist/server.js
  3. 使用一些bundler进行打包,将其打包为单个文件,如esbuild --bundlenode bundle.mjs

很多人其实并不知道这几种方法之间的区别,今天我想通过具体的测试来区分每种方法的不同。

测试目的

把测试拆成两个维度:

  • 启动阶段性能
  • 服务运行阶段性能

因为,我们可以测试三类数据:

  1. 冷启动会差多少?
  2. 稳态吞吐/延迟会差多少?
  3. 资源占用是否存在显著差异?

测试方法

环境

  • 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
  • 资源采样:压测期间每秒采样 RSSCPU%

总体结果

1)冷启动

mode cold(ms)
esbuild 104
tsc 308
tsx 399

冷启动的差异非常明显,esbuild 在冷启动上的表现优于 tsctsx,差距达到 75%。

2)平均吞吐(9 场景)

mode avg req/s
tsc 22114
esbuild 21959
tsx 21928

吞吐量差异小于 1%,可见三者在稳态性能上几乎一致。

3)P95 延迟

三种方式的 P95 延迟几乎完全相同,均为 5-6ms。

4)RSS 内存

三者的内存使用几乎一致,均约为 62MB。

测试数据报告:链接


关键问题 1:为什么冷启动差这么多?

冷启动时间的差异可以拆解为以下几个部分:

  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.exportsexport)存入缓存中,以便后续调用。

在不同的启动方式下,加载的模块数量和方式有所不同:

  • tsc:编译多个 JS 文件。
  • tsx:编译多个 TS 文件。
  • esbuild:生成单一的打包文件。

esbuild 通过打包将多个模块合并为一个文件,减少了模块解析和文件 I/O 的开销,因此冷启动时间显著较短。

  1. 运行时转译成本(仅适用于 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 使用率的“抬高”。

另外很重要的一点,可以看到上面的统计图表中,重要的一点是,吞吐量几乎一致,这意味着无论是 tsxtsc 还是 esbuild,在处理请求时的效率差异都很小。如果 esbuild 确实比其他模式更高效,它的吞吐量应该显著超过其他模式。然而,实际数据表明,差距微乎其微,这表明 CPU 峰值差异 主要来源于 短期的计算开销,而非整体的运行效率差异。

最终性能,尤其是吞吐量,在底层上受 V8 引擎优化和 I/O 处理 等因素的影响更大,在运行层面上应该受到业务逻辑、IO、JSON 序列化、数据库等因素决定。

小结

现在可以回答问题Node 后端服务启动方式的问题了:

在开发环境,生产环境(常驻 API 服务),还是推荐 tsx。性能差别不大,带来了更好的体验。

在如云函数等,冷启动敏感场景,推荐用如 esbuild来提前bundle,本文中的案例,esbuild的冷启动时长比普通tsx快了75%!

Vue实例与数据绑定

2026年2月28日 21:15

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不支持基本类型
对象 refreactive 都可以,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阶段直接执行代码

记住这些要点

  1. 新项目推荐使用<script setup>语法糖
  2. ref是万能选择,reactive仅用于对象
  3. 能用计算属性就不用侦听器
  4. onUnmounted中清理副作用

下一站预告

在下一篇文章《模板语法与指令详解》中,我们将学习:

  • 模板语法详解
  • 常用指令(v-if、v-for、v-bind等)
  • 自定义指令开发

敬请期待!


作者:洋洋技术笔记
发布日期:2026-02-28
系列:Vue.js从入门到精通 - 第2篇

Vue实例与数据绑定详解 | Vue3生命周期、ref、computed与watch完整指南

zzy-scroll-timer:一个跨框架的滚动定时器插件

2026年2月28日 21:03

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 的核心实现思路:

  1. 克隆子元素:为了实现无缝滚动,会将第一个子元素克隆一份放到最后
  2. 定时器控制:使用 setTimeout 控制滚动间隔
  3. CSS 过渡:使用 transformtransition 实现平滑滚动动画

核心代码结构:

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;
    }
  }
}

应用场景

  1. 公告栏滚动:网站首页的公告通知滚动展示
  2. 新闻轮播:新闻列表的自动滚动展示
  3. 数据展示:实时数据的滚动展示
  4. 排行榜:用户排行榜的滚动展示
  5. 消息通知:系统消息的滚动提示

项目地址

总结

zzy-scroll-timer 是一个简单实用的滚动定时器插件,它的优势在于:

  • 跨框架支持,一套代码适配 Vue 2/3 和 React
  • API 设计简洁,上手容易
  • 功能完善,支持自动/手动控制和动态更新
  • TypeScript 支持,类型安全

如果你正在寻找一个轻量级的滚动组件,不妨试试 zzy-scroll-timer


如果这篇文章对你有帮助,欢迎点赞、收藏、评论! 🎉

拆完 Upwork 前端我沉默了:你天天卷的那些技术,人家根本没用

2026年2月28日 20:55

深度逆向分析: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 摘要足够做第一轮粗筛(排除明显不值得的职位),但精确的投标决策必须依赖详情页的 buyerapplicants 数据。


六、基于数据结构的 Connect 风险评估模型

有了完整的数据结构,我们可以设计一套量化的评分体系,帮助 Freelancer 科学地分配有限的 Connects。

6.1 客户画像评分(Client Score)

以下评分标准基于公开的 Upwork 社区经验总结和平台官方的红旗指南,结合本文发现的具体字段设计。所有示例数值均为虚构。

支付验证(权重最高)isPaymentMethodVerified 是最重要的风控信号。已验证得 +25 分,未验证直接 -50 分。Upwork 官方的安全指南也将"未验证支付方式"列为首要红旗之一。

历史花费totalCharges.amount)反映客户的付费意愿和能力。50K以上得+2550K 以上得 +25,10K-50K+2050K 得 +20,1K-10K+1010K 得 +10,1-1K+51K 得 +5,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 > 0freelancersToHire 大于已雇佣人数,可能还有机会。

新鲜度:发布 1 小时内得 +10(早鸟优势明显),1-6 小时得 +5,6-24 小时得 0,超过 24 小时得 -5。wasRenewed=true 表示职位被重新发布,说明上一轮没有找到合适人选,既是机会(竞争者被淘汰)也是警示(可能是需求本身有问题),给 +3 分。

举例说明(虚构数据):假设一个数据处理类固定价格职位,描述约 900 字符(+5),预算 150明确(+10),但要求Expert级别配150 明确(+10),但要求 Expert 级别配 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,希望这篇分析都能带来一些启发。


参考资料

  1. Upwork Engineering Blog: Modernizing Upwork with Micro Frontends (2017) — www.upwork.com/careers/eng…
  2. Upwork Engineering Blog: Upwork Modernization: An Overview (2017) — www.upwork.com/careers/eng…
  3. Upwork Developer Portal & GraphQL API Documentation — www.upwork.com/developer
  4. Upwork Resources: Client Red Flagswww.upwork.com/resources/c…
  5. Upwork Resources: Spotting Fake Job Postswww.upwork.com/resources/s…
  6. 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,让你的组件更简洁

2026年2月28日 20:39

从入门到进阶:手写React自定义Hooks,让你的组件更简洁

大家好,今天我们来聊聊React中非常实用的自定义Hooks。通过两个实际例子(鼠标位置追踪和Todo待办应用),带你从零开始封装自己的Hooks,彻底理解“逻辑复用”的魅力,并掌握如何避免常见的内存泄漏问题。

什么是Hooks?

Hooks是React 16.8引入的一种函数式编程思想,它让我们在函数组件中使用状态和生命周期等特性。React内置了useStateuseEffect等基础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 函数仍然存在于内存中,并且每次鼠标移动都会尝试调用 setXsetY。但此时组件已经被销毁,这些 setState 调用是无意义的,并且会导致内存泄漏——事件处理函数持有对组件作用域的引用,垃圾回收无法释放相关内存。长时间运行的应用可能会因此变得卡顿甚至崩溃。

验证方法:注释掉 return () => {...} 这一部分,然后反复点击按钮让 MouseMove 组件挂载/卸载,观察控制台。你会发现即使组件卸载了,鼠标移动时控制台依然打印“添加事件监听”(实际上并没有重新添加,但之前添加的监听还在),说明事件处理函数依然存活。这就是内存泄漏的表现。

没清理的效果图

我们看到就算函数已经卸载,事件依然会执行 屏幕录制 2026-02-28 202959.gif

清理后的效果图

可以看到,函数卸载后,事件不会执行了 屏幕录制 2026-02-28 203448.gif

第二部分:提取自定义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 的 useStateuseEffect
  • 返回值:一个包含 xy 的对象,类型均为 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。
效果图

屏幕录制 2026-02-28 203724.gif

第四部分:深入理解内存泄漏与清理的必要性

在React函数组件中,useEffect 是处理副作用的主要场所。常见的副作用包括:

  • 订阅外部事件(如 mousemoveresize、WebSocket)
  • 设置定时器(setIntervalsetTimeout
  • 手动修改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的强大:

  1. useMouse:将副作用(事件监听)和状态封装起来,组件只需调用并渲染,同时自动处理了内存泄漏的清理逻辑。
  2. useTodos:不仅管理状态,还集成了本地存储持久化,让UI组件完全无感。

自定义Hooks让我们能够像搭积木一样组合逻辑,保持组件简洁,提升代码复用性。在实际项目中,你可以根据自己的业务封装更多通用Hooks,比如 useLocalStorageuseFetchuseWindowSize 等。

最后请记住:每当你在 useEffect 中引入持续性的副作用(事件、定时器、订阅),务必返回一个清理函数,这是React函数组件中防止内存泄漏的基本准则。

希望这篇文章能帮你打开自定义Hooks的大门,快去动手试试吧!

LeetCode 130. 被围绕的区域:两种解法详解(BFS/DFS)

作者 Wect
2026年2月28日 18:53

LeetCode 中等难度题目「130. 被围绕的区域」,这道题是典型的图的连通性问题,核心考察 BFS 和 DFS 的实际应用,还能帮我们理清“边界判断”的关键逻辑,新手也能轻松上手。

先先明确题目核心需求,避免踩坑:给一个 m x n 的矩阵,由 'X' 和 'O' 组成,我们要“捕获”所有被围绕的 'O',并原地替换成 'X';而不被围绕的 'O'(只要和矩阵边缘的 'O' 连通,就不算被围绕),要保留下来。

先划重点(题目隐藏陷阱):

  • 连接:仅水平、垂直相邻(斜向不算);

  • 被围绕:整个 'O' 区域完全不接触矩阵边缘,且被 'X' 包围;

  • 要求:原地修改矩阵,无需返回值。

拿到这道题,第一反应可能是“遍历每个 'O',判断它是否被包围”,但这样容易绕弯路(比如重复判断连通区域)。其实换个思路更高效:先找到所有不被包围的 'O'(边缘连通的),标记出来,剩下的 'O' 就是被包围的,直接替换成 'X' 即可

下面分别讲解两种解法,一种是“正向判断连通区域”(BFS),一种是“反向标记边缘连通区域”(DFS),附完整代码和详细解析。

解法一:BFS 正向遍历 + 连通区域判断(solve_1)

思路核心

遍历矩阵中每个 'O',用 BFS 遍历它所在的整个连通区域,同时判断这个区域是否“触达边缘”:

  1. 如果连通区域中有任意一个 'O' 在矩阵边缘 → 不被包围,保留为 'O';

  2. 如果连通区域所有 'O' 都不在边缘 → 被包围,全部替换为 'X';

  3. 用临时标记 'A' 避免重复遍历(遍历过程中先把 'O' 改成 'A',后续根据是否被包围,再改回 'O' 或改成 'X')。

完整代码(TypeScript)

/**
 Do not return anything, modify board in-place instead.
 */
function solve_1(board: string[][]): void {
  if (board.length === 0 || board[0].length === 0) {
    return;
  }
  const rows = board.length;
  const cols = board[0].length;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (board[i][j] === 'O') {
        const queue: [number, number][] = []; // 存储当前连通区域的所有坐标(用于后续修改)
        const tempQueue: [number, number][] = []; // BFS遍历队列(用于扩散连通区域)
        let isSurround = true; // 标记当前连通区域是否被包围

        tempQueue.push([i, j]);
        board[i][j] = 'A'; // 临时标记,避免重复遍历
        queue.push([i, j]);

        // BFS遍历连通区域(上下左右四个方向)
        while (tempQueue.length > 0) {
          const [x, y] = tempQueue.shift()!; // BFS用shift()(队列:先进先出),DFS用pop()(栈:后进先出)

          // 关键判断:只要有一个坐标在边缘,当前区域就不被包围
          if (x === 0 || x === rows - 1 || y === 0 || y === cols - 1) {
            isSurround = false;
          }

          // 上:判断边界 + 是'O',才继续遍历
          if (x > 0 && board[x - 1][y] === 'O') {
            board[x - 1][y] = 'A';
            tempQueue.push([x - 1, y]);
            queue.push([x - 1, y]);
          }
          // 下
          if (x < rows - 1 && board[x + 1][y] === 'O') {
            board[x + 1][y] = 'A';
            tempQueue.push([x + 1, y]);
            queue.push([x + 1, y]);
          }
          // 左
          if (y > 0 && board[x][y - 1] === 'O') {
            board[x][y - 1] = 'A';
            tempQueue.push([x, y - 1]);
            queue.push([x, y - 1]);
          }
          // 右
          if (y < cols - 1 && board[x][y + 1] === 'O') {
            board[x][y + 1] = 'A';
            tempQueue.push([x, y + 1]);
            queue.push([x, y + 1]);
          }
        }

        // 根据是否被包围,修改当前连通区域的所有坐标
        if (isSurround) {
          // 被包围:替换为X
          for (const [x, y] of queue) {
            board[x][y] = 'X';
          }
        } else {
          // 不被包围:恢复为O
          for (const [x, y] of queue) {
            board[x][y] = 'O';
          }
        }
      }
    }
  }
};

关键细节 & 易错点

  • 两个队列的作用:tempQueue 用于 BFS 扩散遍历,queue 用于记录当前连通区域的所有坐标(方便后续批量修改),缺一不可;

  • 临时标记 'A':避免同一 'O' 被多次遍历(比如相邻的 'O' 重复触发 BFS),提升效率;

  • 边缘判断时机:遍历连通区域的每个坐标时,只要有一个坐标触达边缘,就立即将 isSurround 设为 false(无需继续判断该区域的其他坐标);

  • BFS vs DFS:这里用 shift() 实现 BFS(队列),如果换成 pop(),就是 DFS(栈),逻辑完全一致,只是遍历顺序不同。

复杂度分析

时间复杂度:O(m×n),每个单元格最多被遍历一次(临时标记 'A' 避免重复);

空间复杂度:O(m×n),最坏情况下(全是 'O'),两个队列会存储所有单元格坐标。

解法二:DFS 反向标记 + 批量修改(solve_2)

这是更高效、更简洁的解法,核心思路是“反向操作”:先标记所有不被包围的 'O'(边缘连通的),再批量处理剩余的 'O' 和标记

逻辑比解法一更清晰:边缘的 'O' 一定不被包围,它们连通的 'O' 也不被包围,先把这些 'O' 标记为 'A';最后遍历整个矩阵,把 'O'(被包围的)改成 'X',把 'A'(不被包围的)改回 'O'。

完整代码(TypeScript)

function solve_2(board: string[][]): void {
  const rows = board.length;
  if (rows === 0) return; // 边界处理:空矩阵直接返回
  const cols = board[0].length;
  const visited = new Set<string>(); // 可选:用于标记已遍历的边缘连通O,避免重复(本题可省略,因标记为'A'已实现去重)

  // DFS辅助函数:标记边缘连通的O为'A'
  const helper = (board: string[][], x: number, y: number, rows: number, cols: number): void => {
    // 边界判断 + 当前位置不是O(无需标记)
    if (
      x < 0 || x >= rows ||
      y < 0 || y >= cols ||
      board[x][y] !== 'O'
    ) {
      return;
    }

    // 标记为A(表示是边缘连通的O,不替换)
    board[x][y] = 'A';

    // 递归遍历上下左右四个方向(DFS核心:深度优先扩散)
    helper(board, x - 1, y, rows, cols); // 上
    helper(board, x + 1, y, rows, cols); // 下
    helper(board, x, y - 1, rows, cols); // 左
    helper(board, x, y + 1, rows, cols); // 右
  }

  // 第一步:遍历矩阵边缘,标记所有边缘连通的O为'A'
  // 遍历第一行和最后一行(所有列)
  for (let j = 0; j < cols; j++) {
    helper(board, 0, j, rows, cols);          // 第一行
    helper(board, rows - 1, j, rows, cols);   // 最后一行
  }
  // 遍历第一列和最后一列(排除已遍历的行边缘,避免重复)
  for (let i = 1; i < rows - 1; i++) {
    helper(board, i, 0, rows, cols);          // 第一列
    helper(board, i, cols - 1, rows, cols);   // 最后一列
  }

  // 第二步:批量修改矩阵
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      if (board[i][j] === 'O') {
        // 未被标记的O → 被包围,替换为X
        board[i][j] = 'X';
      } else if (board[i][j] === 'A') {
        // 标记过的O → 边缘连通,恢复为O
        board[i][j] = 'O';
      }
      // X保持不变,无需处理
    }
  }
};

关键细节 & 优化点

  • 反向思路的优势:无需判断每个连通区域是否被包围,只需要处理边缘及其连通的 'O',逻辑更简洁,代码量更少;

  • DFS 辅助函数:递归终止条件要完整(边界 + 非 'O'),避免数组越界;

  • 边缘遍历优化:先遍历第一行、最后一行(所有列),再遍历第一列、最后一列(排除首尾行),避免重复遍历边缘单元格;

  • visited 集合:本题可省略,因为我们用 'A' 标记了已遍历的 'O',再次遇到 'A' 时会被递归终止条件过滤;但如果不想修改原矩阵(本题要求原地修改,所以无需),可以用 visited 记录已遍历坐标。

复杂度分析

时间复杂度:O(m×n),每个单元格最多被遍历两次(一次标记,一次批量修改);

空间复杂度:O(m×n),最坏情况下(全是 'O'),递归栈深度会达到 m×n(可优化为迭代 DFS,降低空间复杂度到 O(min(m,n)))。

两种解法对比 & 选择建议

解法 核心思路 优点 缺点 适用场景
solve_1(BFS正向) 遍历每个O,判断连通区域是否被包围 逻辑直观,容易理解,适合新手 需要两个队列,空间开销稍大 新手入门,理解连通区域判断逻辑
solve_2(DFS反向) 标记边缘连通O,再批量修改 代码简洁,效率更高,空间更优 递归可能栈溢出(可优化为迭代) 实际刷题、面试(推荐写法)

面试高频考点 & 避坑指南

  • 核心考点:图的连通性(BFS/DFS)、原地修改技巧、边界判断;

  • 常见坑1:忘记处理空矩阵(board.length === 0),导致数组越界;

  • 常见坑2:边缘判断不完整(漏判某一行/一列),导致部分边缘O被误判为被包围;

  • 常见坑3:重复遍历(未用临时标记),导致超时;

  • 优化技巧:DFS 递归栈溢出时,可改为迭代 DFS(用栈模拟递归),或直接用 BFS 实现反向标记。

总结

「被围绕的区域」本质是“连通区域的边界判断”,核心思路有两种:正向判断每个连通区域是否触达边缘,或反向标记边缘连通的区域再批量处理。

实际刷题中,解法二(DFS反向标记) 更推荐,代码简洁、效率更高,也是面试中常考的最优写法;解法一适合新手理解连通区域的遍历逻辑,打好基础。

建议大家动手敲一遍代码,对比两种解法的执行过程,重点体会“临时标记”和“反向思路”的妙用——很多图论问题,换个角度就能简化逻辑。

Markdown 预解析:别等全文完了再渲染,如何流式增量渲染代码块和公式?

2026年3月1日 08:23

传统的 Markdown 渲染逻辑(如 marked(fullText))在 AI 流式输出面前就是个“性能黑洞”。

如果每来一个 Token 就把几千字的全文重绘一遍,不仅 CPU 会因为重复的正则匹配而爆表,更糟糕的是 UI 体验:由于解析器还没看到结尾的 ```$$,代码块和公式会频繁地在“纯文本”和“渲染态”之间反复横跳(Flicker)

要做到“流式且无损”,我们需要一套状态机驱动的增量预解析方案


1. 核心原理:解析器的“状态化”

传统的解析器是“无状态”的。而流式解析器需要记住: “我现在处于什么环境?”

我们可以将解析过程拆解为三个核心状态:

  1. TEXT:常规文本模式。
  2. CODE:检测到 ```,进入代码块模式。
  3. MATH:检测到 $$[,进入公式模式。

2. 代码实现:StreamMarkdownRenderer 逻辑

核心思路是:在闭合标签到达之前,先“假装”它已经闭合了,并创建一个占位节点进行增量更新。

JavaScript

class StreamMarkdownRenderer {
  constructor(container) {
    this.container = container;
    this.currentState = 'TEXT';
    this.activeNode = null; // 当前正在被填充的 DOM 节点
    this.buffer = '';       // 未处理的碎片缓存
  }

  push(token) {
    this.buffer += token;
    this.parse();
  }

  parse() {
    // 逻辑简化:检测 buffer 中的特殊标识符
    if (this.buffer.includes('```') && this.currentState === 'TEXT') {
      this.currentState = 'CODE';
      this.activeNode = this.createCodeBlock();
      this.buffer = this.buffer.split('```')[1]; // 移除标识符
    } 
    else if (this.buffer.includes('$$') && this.currentState === 'TEXT') {
      this.currentState = 'MATH';
      this.activeNode = this.createMathBlock();
      this.buffer = this.buffer.split('$$')[1];
    }

    this.updateActiveNode();
  }

  updateActiveNode() {
    if (!this.activeNode) {
      // 普通文本追加
      this.renderText(this.buffer);
      this.buffer = '';
      return;
    }

    // 增量填充代码或公式
    if (this.currentState === 'CODE') {
      this.activeNode.querySelector('code').textContent += this.buffer;
      // 触发高亮(如 Prism.highlightElement)
      this.highlight(this.activeNode);
    } 
    else if (this.currentState === 'MATH') {
      // 增量渲染 KaTeX
      this.renderKaTeX(this.activeNode, this.buffer);
    }
    this.buffer = '';
  }
}

3. 针对不同类型的进阶处理

① 代码块:语法高亮的“预热”

在流式状态下,Shiki 虽然美观但太重,Prism.js 是更好的选择。

  • 技巧:不要在每个字符进来时都调用 highlight。利用我们之前聊过的 requestAnimationFrame,每隔 50ms 左右进行一次重排高亮,这样既能保证“语法变色”,又不会卡死主线程。

② 数学公式:KaTeX 的“平滑修正”

公式渲染最怕的是 a+b=ca + b = c 还没打完,公式就显示报错红叉。

  • 技巧:使用 try-catch 包裹 katex.render。如果当前内容不完整导致报错,则先以纯文本样式显示内容,直到检测到完整的公式语法。

E=mc2E = mc^2

(这是公式渲染态)


4. 3 个“防坑”策略

  1. “假闭合”处理

    AI 偶尔会断网或中断,导致 ``` 永远不出现。你的 Renderer 必须有一个 Auto-Close 机制:如果解析器进入 CODE 状态后 5 秒没有新内容,自动强行闭合它。

  2. 避免 HTML 标签截断

    如果你在流式渲染中允许 HTML(如 <br>),一定要小心 Token 刚好把 <br> 劈成 <br> 的情况。策略:如果 buffer 的结尾包含 <& 等起始符,先憋住不发,等下一个 Token 凑整。

  3. 计算属性的“懒加载”

    对于公式和代码块,不要频繁查询 scrollHeight。在大规模 AI 响应中,频繁的布局查询会触发浏览器的同步布局震荡(Layout Thrashing)。


5. 效果对比

方案 刷新率 稳定性 CPU 消耗
全量重绘 随 Token 频率 极差 (代码块反复闪烁) O(N2)O(N^2),字越多越卡
增量预解析 随屏幕刷新率 极高 (平滑增长) O(1)O(1)O(N)O(N),恒定稳定

打字机效果优化:用 requestAnimationFrame 缓冲高频文字更新

2026年3月1日 08:23

你一定知道浏览器最怕的不是“大量数据”,而是**“高频小动作”**。

当 AI 通过 SSE(Server-Sent Events)流式返回数据时,由于网络包的大小不一,可能会出现一秒内触发几十次甚至上百次 DOM 更新的情况。如果你直接在接收到消息时就修改 innerText,浏览器会陷入不断的 Reflow(回流)Repaint(重绘) 循环中。这不仅会让风扇狂转,还会导致输入框卡顿、动画掉帧,甚至让你的 AI Prompt Manager 看起来像个廉价的半成品。


1. 核心矛盾:网络频率 vs. 屏幕频率

  • 网络推送:可能每 10ms 就来一个 Token。
  • 屏幕刷新:大多数显示器是 60Hz(约每 16.7ms 刷新一次)。
  • 后果:如果在 16.7ms 内你更新了 5 次 DOM,浏览器实际上只能显示最后一次,前 4 次的计算全是浪费的 CPU 垃圾时间

2. 优化方案:构建“文字缓冲区”

我们的思路是:不要来一个字蹦一个字,而是建立一个中间缓冲区(Buffer) 。无论网络多快,我们都只跟着浏览器的 requestAnimationFrame (rAF) 节奏走。

实战代码实现

JavaScript

class TypewriterBuffer {
  constructor(container) {
    this.container = container;
    this.buffer = "";      // 待渲染的文字池
    this.isRendering = false;
  }

  // 1. 接收网络推送的数据
  push(chunk) {
    this.buffer += chunk;
    this.requestRender();
  }

  // 2. 触发渲染逻辑
  requestRender() {
    if (this.isRendering) return; // 已经在跑了,别催
    this.isRendering = true;

    requestAnimationFrame(() => {
      this.flush();
    });
  }

  // 3. 执行真正的 DOM 更新
  flush() {
    if (this.buffer.length > 0) {
      // 这里的逻辑可以更复杂:比如一次只从 buffer 拿 2 个字,模拟更自然的打字感
      const fragment = document.createTextNode(this.buffer);
      this.container.appendChild(fragment);
      
      this.buffer = ""; // 清空缓冲
    }
    
    this.isRendering = false;
  }
}

// 使用方式
const logger = new TypewriterBuffer(document.getElementById('ai-response'));
sse.onmessage = (e) => logger.push(e.data);

3. 进阶:模拟“真人打字”的韵律感

AI 现在的流式输出有时太快,快到人眼根本读不过来。资深开发会在这里加一点“演技”:即使数据已经到了,我们也匀速释放。

JavaScript

// 在 flush 中增加步长控制
flush() {
  const step = Math.ceil(this.buffer.length / 5); // 动态步长,量大时快点,量小时慢点
  const textToAppend = this.buffer.slice(0, step);
  this.buffer = this.buffer.slice(step);

  this.container.lastChild.textContent += textToAppend;

  if (this.buffer.length > 0) {
    requestAnimationFrame(() => this.flush()); // 递归渲染剩余字符
  } else {
    this.isRendering = false;
  }
}

4. 性能避坑指南

  1. 避免 innerHTML:在流式场景下,严禁使用 innerHTML += chunk。这会导致浏览器解析整个字符串并重新构建所有 DOM 节点。请使用 textContentappendChild(TextNode)
  2. 长文本崩溃:如果 AI 回复了 5000 字,lastChild.textContent 的性能也会下降。建议每 1000 个字封装进一个独立的 <div><span>
  3. 合并更新:如果你的 AI 包含 Markdown 解析(如 marked.js),不要每来一个字都调用一次解析器。应该利用 rAF 的间隙,每隔 5-10 个 Token 解析一次全量 Markdown。
  4. CPU 保护:当页面处于后台(用户切走了)时,requestAnimationFrame 会自动暂停。这正是我们要的效果:用户看不见时,不浪费一丁点 CPU 算力。

5. 效果对比

维度 直接 DOM 更新 rAF 缓冲区更新
CPU 占用 剧烈波动,易出现峰值 平滑且稳定
渲染流畅度 视觉闪烁,可能有撕裂感 丝滑,符合屏幕刷新率
主线程压力 持续高压,响应输入慢 间歇性工作,输入依旧流畅
适用场景 简单、低频更新 AI 聊天、金融实时行情

用 CSS 打造完美的饼图

2026年2月28日 23:59

原文:Trying to Make the Perfect Pie Chart in CSS

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

说到图表……你上次使用饼图是什么时候?如果你是那些需要到处做演示的人之一,那么恭喜!你既在我个人的地狱里……也被饼图包围着。幸运的是,我想我很久没需要用过它们了,至少直到最近是这样。

去年,我自愿为墨西哥的一个儿童慈善机构制作网页。一切都很标准,但工作人员希望在他们的落地页上以饼图展示一些数据。他们给我们的时间不多,所以我承认我走了捷径,使用了众多用于制作图表的 JavaScript 库之一。

看起来不错,但内心深处我感到不安;为几个简单的饼图引入整个库。感觉像是走捷径,而不是打造真正的解决方案。

我想弥补这一点。在本文中,我们将尝试用 CSS 制作完美的饼图。这意味着在解决手写饼图带来的主要头痛问题的同时,尽可能减少 JavaScript。但首先,让我们设定我们的「完美」应该遵守的一些目标。

按优先级排序:

  1. 应该将 JavaScript 保持在最低限度!不是对 JavaScript 有意见,只是这样更有趣。
  2. 应该是 HTML 可定制的!一旦 CSS 完成,我们只需要修改标记就可以自定义饼图。
  3. 必须是语义化的!这意味着屏幕阅读器应该能够理解饼图中显示的数据。

完成后,我们应该得到像这样的饼图:

这要求太多吗?也许吧,但无论如何我们会试试。

圆锥渐变(conic gradients)不是最佳选择

我们不能在谈论饼图时不先谈谈圆锥渐变。如果你读过任何与 conic-gradient() 函数相关的内容,那么你可能已经看到它们可以用来在 CSS 中创建简单的饼图。见鬼,甚至我在年鉴条目中也这么说过。为什么不呢?只需要一个元素和一行 CSS……

.gradient {
  background: conic-gradient(blue 0% 12.5%, lightblue 12.5% 50%, navy 50% 100%);
}

我们可以得到无缝完美的饼图:

CodePen Embed Fallback

然而,这种方法公然违背了我们语义化饼图的第一个目标。正如同一条目后面所指出的:

不要使用 conic-gradient() 函数创建真正的饼图或任何其他信息图。它们不包含任何语义含义,应仅用于装饰目的。

请记住,渐变是图像,因此将渐变显示为 background-image 不会告诉屏幕阅读器关于饼图本身的任何信息;它们只能看到一个空元素。

这也违背了我们的第二条规则,即让饼图可通过 HTML 定制,因为对于每个饼图,我们都必须更改其对应的 CSS。

那么我们是否应该完全抛弃 conic-gradient()?尽管我很想这么做,但它的语法太好了,不能错过,所以让我们至少尝试弥补它的缺点,看看能带我们走到哪里。

改进语义

conic-gradient() 第一个也是最严重的问题是它的语义。我们想要一个包含所有数据的丰富标记,以便屏幕阅读器能够理解。我必须承认我不知道语义化书写的最佳方式,但在使用 NVDA 测试后,我相信这是一个足够好的标记:

<figure>
  <figcaption>上月售出的糖果</figcaption>
  <ul class="pie-chart">
    <li data-percentage="35" data-color="#ff6666"><strong>巧克力</strong></li>
    <li data-percentage="25" data-color="#4fff66"><strong>软糖</strong></li>
    <li data-percentage="25" data-color="#66ffff"><strong>硬糖</strong></li>
    <li data-percentage="15" data-color="#b366ff"><strong>泡泡糖</strong></li>
  </ul>
</figure>

理想情况下,这就是我们饼图所需要的全部,一旦样式完成,只需编辑 data-* 属性或添加新的 <li> 元素即可更新我们的饼图。

不过有一点:在目前的状态下,data-percentage 属性不会被屏幕阅读器朗读出来,所以我们必须将它作为伪元素附加到每个项目的末尾。记得在末尾加上「%」以便一起朗读:

.pie-chart li::after {
  content: attr(data-percentage) "%";
}

CodePen Embed Fallback

那么,它是否具有可访问性?至少在 NVDA 中测试时是的。这是 Windows 上的效果:

你可能对我为什么选择这个或那个有一些疑问。如果你信任我,我们继续,但如果不,这是我的思考过程:

为什么使用 data 属性而不是直接写入每个百分比?

我们很容易将它们写在每个 <li> 里面,但使用属性我们可以通过 attr() 函数在 CSS 中获取每个百分比。正如我们稍后将看到的,这使得在 CSS 中使用它变得容易得多。

为什么用 <figure>

<figure> 元素可以作为我们饼图的自包含包装器使用,除了图像之外,它也经常用于图表。很方便,因为我们可以通过 <figcaption> 给它一个标题,然后在无序列表中写出数据,我之前不知道 figure 允许的内容 中包括 ul 作为流内容

为什么不用 ARIA 属性?

我们可以使用 aria-description 属性让屏幕阅读器朗读每个项目对应的百分比,这可能是最重要的部分。然而,我们可能也需要在视觉上显示图例。这意味着在语义和视觉上都有百分比没有优势,因为它们可能会被朗读两次:(1)在 aria-description 上一次,(2)在伪元素上又一次。

做成饼图

我们已经在纸上有了数据。现在是时候让它看起来像一个真正的饼图了。我首先想到的是,「这应该很容易,有了标记,我们现在可以使用 conic-gradient() 了!」

嗯……我大错特错了,但不是因为语义,而是因为 CSS 层叠的工作原理。

让我们再看看 conic-gradient() 的语法。如果我们有以下数据:

  • 项目 3:50%
  • 项目 2:35%
  • 项目 1:15%

……那么我们会写下以下 conic-gradient()

.gradient {
  background: 
    conic-gradient(
      blue 0% 15%, 
      lightblue 15% 50%, 
      navy 50% 100%
    );
}

这基本上是说:「从 0 到 15% 画第一种颜色,下一种颜色从 15% 到 50%(所以差值是 35%),以此类推。」

你看到问题了吗?饼图是在单个 conic-gradient() 中绘制的,这等于单个元素。你可能看不到,但这很糟糕!如果我们想在 data-percentage 中显示每个项目的权重——让一切更漂亮——那么我们需要一种从父元素访问所有这些百分比的方法。这是不可能的!

我们能够利用 data-percentage 简单性的唯一方法是每个项目绘制自己的扇形。然而,这并不意味着我们不能使用 conic-gradient(),而是我们需要使用多个。

计划是让每个项目都有自己的 conic-gradient() 绘制其扇形,然后将它们全部叠在一起:

为此,我们首先给每个 <li> 一些尺寸。我们不会硬编码大小,而是定义一个 --radius 属性,这在后面保持样式可维护时会很有用。

.pie-chart li {
  --radius: 20vmin;

  width: calc(var(--radius) * 2); /* 半径的两倍 = 直径 */
  aspect-ratio: 1;
  border-radius: 50%;
}

然后,我们使用 attr() 及其新类型语法data-percentage 属性引入 CSS,该语法允许我们将属性解析为字符串以外的内容。请注意,在我写这篇文章时,新语法目前仅限于 Chromium。

然而,在 CSS 中使用小数(如 0.1)比使用百分比(如 10%)更好,因为我们可以将它们乘以其他单位。所以我们将 data-percentage 属性解析为 <number>,然后除以 100 得到小数形式的百分比。

.pie-chart li {
  /* ... */
  --weighing: calc(attr(data-percentage type(<number>)) / 100);
}

我们仍然需要它作为百分比,这意味着将结果乘以 1%

.pie-chart li {
  /* ... */
  --percentage: calc(attr(data-percentage type(<number>)) * 1%);
}

最后,我们再次使用 attr() 从 HTML 获取 data-color 属性,但这次使用 <color> 类型而不是 <number>

.pie-chart li {
  /* ... */
  --bg-color: attr(data-color type(<color>));
}

让我们暂时把 --weighing 变量放在一边,使用另外两个变量创建 conic-gradient() 扇形。它们应该从 0% 到所需百分比,然后 thereafter 变为透明:

.pie-chart li {
  /* ... */
   background: conic-gradient(
   var(--bg-color) 0% var(--percentage),
   transparent var(--percentage) 100%
  );
}

我显式定义了起始 0% 和结束 100%,但由于这些是默认值,我们 technically 可以删除它们。

这是我们目前的进度:

CodePen Embed Fallback

如果你的浏览器不支持新的 attr() 语法,也许一张图片会有所帮助:

现在所有扇形都完成了,你会注意到每个扇形都从顶部开始,顺时针方向延伸。我们需要将它们定位成,你知道的,饼图形状,所以下一步是适当旋转它们以形成圆形。

就在这时我们遇到了一个问题:每个扇形旋转的量取决于它前面的项目数量。我们必须将项目旋转前面扇形的大小。理想情况下,有一个累加器变量(如 --accum)保存每个项目之前百分比的总和。然而,由于 CSS 层叠的工作方式,我们既不能在兄弟之间共享状态,也不能在每个兄弟上更新变量。

相信我,我真的努力绕过这些问题。但我们似乎被迫在两个选项之间做出选择:

  1. 使用 JavaScript 计算 --accum 变量。
  2. 在每个 <li> 元素上硬编码 --accum 变量。

如果我们重新审视我们的目标,选择并不难:硬编码 --accum 会否定灵活的 HTML,因为移动项目或更改百分比会迫使我们再次手动计算 --accum 变量。

然而,JavaScript 使这变得微不足道:

const pieChartItems = document.querySelectorAll(".pie-chart li");

let accum = 0;

pieChartItems.forEach((item) => {
  item.style.setProperty("--accum", accum);
  accum += parseFloat(item.getAttribute("data-percentage"));
});

有了 --accum,我们可以使用 from 语法 旋转每个 conic-gradient(),该语法告诉圆锥渐变旋转的起点。问题是它只接受角度,不接受百分比。(我觉得百分比也应该可以工作,但这是另一个话题)。

为了解决这个问题,我们必须创建另一个变量——我们称它为 --offset——它等于转换为角度的 --accum。这样,我们可以将值插入每个 conic-gradient()

.pie-chart li {
  /* ... */
  --offset: calc(360deg * var(--accum) / 100);

  background: conic-gradient(
    from var(--offset),
    var(--bg-color) 0% var(--percentage),
    transparent var(--percentage) 100%
  );
}

我们看起来好多了!

CodePen Embed Fallback

剩下的就是把所有项目叠在一起。当然有很多方法可以做到这一点,但最简单的可能是 CSS Grid。

.pie-chart {
  display: grid;
  place-items: center;
}

.pie-chart li {
  /* ... */
  grid-row: 1;
  grid-column: 1;
}

这几行 CSS 将所有扇形排列在 .pie-chart 容器的正中心,每个扇形覆盖容器的唯一行和列。它们不会碰撞,因为它们被正确旋转了!

CodePen Embed Fallback

除了那些重叠的标签,我们的状态真的非常非常好!让我们清理一下。

定位标签

现在,<li> 里面的名称和百分比标签彼此散落在一起。我们希望它们浮动在各自扇形的旁边。为了修复这个问题,让我们首先使用与容器本身相同的网格居中技巧,将所有项目移动到 .pie-chart 容器的中心:

.pie-chart li {
  /* ... */
  display: grid;
  place-items: center;
}

.pie-chart li::after,
strong {
  grid-row: 1;
  grid-column: 1;
}

幸运的是,我已经探索过如何使用较新的 CSS 的 cos()sin() 在圆上布局东西。去看看那些链接,因为那里有很多上下文。简而言之,给定一个角度和半径,我们可以使用 cos()sin() 来获取圆上每个项目的 X 和 Y 坐标。

为此,我们需要——你猜对了!——另一个表示角度的 CSS 变量(我们称之为 --theta),我们将在那里放置每个标签。我们可以用下一个公式计算该角度:

.pie-chart li {
  /* ... */
  --theta: calc((360deg * var(--weighing)) / 2 + var(--offset) - 90deg);
}

值得了解该公式在做什么:

  • - 90degcos()sin() 的角度从右边测量,但 conic-gradient() 从顶部开始。这部分通过 -90deg 校正每个角度。
  • + var(--offset):移动角度以匹配当前偏移。
  • 360deg * var(--weighing)) / 2:将百分比作为角度获取,然后除以二以找到中点。

我们可以使用 --theta--radius 变量找到 X 和 Y 坐标,如下面的伪代码:

x = cos(theta) * radius
y = sin(theta) * radius

翻译成……

.pie-chart li {
  /* ... */
  --pos-x: calc(cos(var(--theta)) * var(--radius));
  --pos-y: calc(sin(var(--theta)) * var(--radius));
}

这会将每个项目放在饼图的边缘,所以我们会在它们之间添加一个 --gap

.pie-chart li {
  /* ... */
  --gap: 4rem;
  --pos-x: calc(cos(var(--theta)) * (var(--radius) + var(--gap)));
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)));
}

然后我们用 --pos-x--pos-y 平移每个标签:

.pie-chart li::after,
strong {
  /* ... */
  transform: translateX(var(--pos-x)) translateY(var(--pos-y));
}

哦等等,还有一个小细节。每个项目的标签和百分比仍然叠在一起。幸运的是,修复就像在 Y 轴上再多平移一点百分比一样简单:

.pie-chart li::after {
  --pos-y: calc(sin(var(--theta)) * (var(--radius) + var(--gap)) + 1lh);
}

现在我们在用煤气做饭了!

CodePen Embed Fallback

让我们确保这对屏幕阅读器友好:

暂时就这些……

我会称这是朝着「完美」饼图迈出的非常好的第一步,但仍有一些我们可以改进的地方:

  • 这似乎迫切需要一种漂亮的悬停效果,比如 maybe 放大扇形并显示它?
  • 不同类型的图表呢?柱状图,有人要吗?
  • data-color 属性很好,但如果没有提供,我们仍然应该提供一种让 CSS 生成颜色的方式。也许是 color-mix() 的好工作?
  • 饼图假设你会自己写百分比,但应该有一种方式输入原始项目数量,然后计算它们的百分比。

这就是我目前能想到的全部,但我已经在计划在后续文章中逐步解决这些问题(懂吗?!)。此外,没有大量反馈就没有完美,所以告诉我你会改变或添加什么到这个饼图中,让它真正完美!


纯 CSS 实现弹性文字效果

2026年3月1日 00:00

原文:How to Create a CSS-only Elastic Text Effect

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

每个字母单独动画的文字效果总是很酷、很吸睛。这类错峰动画通常依赖 JavaScript 库实现,对我们要实现的这种相对轻量的设计效果来说,代码往往偏重。本文将探索只用 CSS、无需 JavaScript 实现 fancy 文字效果的技巧(意味着需要手动拆分字符)。

截至撰写时,仅 Chrome 和 Edge 完全支持我们使用的特性。

将鼠标悬停在下方演示的文字上,即可看到效果:

CodePen Embed Fallback

很酷吧?仅靠 CSS 就实现了逼真的弹性效果,而且灵活易调。在深入代码之前,先做一个重要声明。这个效果不错,但有几个明显的缺点。

关于可访问性的重要声明

我们要做的效果依赖于把单词拆成单个字母,一般来说这种做法非常不推荐。

一个简单链接通常是这样写的:

<a href="#">About</a>Code language: HTML, XML (xml)

但要分别控制每个字母的样式,我们会改成这样:

<a href="#">
  <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
</a>Code language: HTML, XML (xml)

这会带来可访问性问题。

很容易想到用 aria-* 属性来弥补。至少我之前是这么想的。网上有不少资料推荐类似下面的结构:

<a href="#" aria-label="About">
  <span aria-hidden="true">
    <span>A</span><span>b</span><span>o</span><span>u</span><span>t</span>
  </span>
</a>Code language: HTML, XML (xml)

看起来没问题吧?不!这种结构依然很糟糕。实际上,网上能找到的大多数结构都有问题。我不是这个领域的专家,所以请教了一些人,发现 Adrian Roselli 的两篇博客很有参考价值:

强烈建议读一读,理解为什么把单词拆成字母是个坏主意(以及可能的替代方案)。

那我为什么还要做这个演示?

我更倾向于把它当作一次探索现代 CSS 特性的实验。这个效果里可能有很多你还不熟悉的属性,是了解它们的好机会。可以用在娱乐或 side project 中,但在广泛使用或关键场景中引入前,请三思。

好了,声明完毕,我们开始。

原理说明

思路是使用 offset() 属性,定义字母沿一条路径运动。这条路径是一条曲线,我们沿曲线做动画。offset() 是一个被低估的特性,但潜力很大,尤其配合现代 CSS 使用时。我曾用它做过无限跑马灯动画、让元素沿圆精确排布、做图片画廊等。

下面是一个简化示例,帮助理解我们要用的技巧:

CodePen Embed Fallback

上面的演示使用了来自 SVG 的 path() 值。三个字母最初沿第一条路径,悬停时切换到第二条路径。借助 transition,就形成了平滑的效果。

可惜的是,使用 SVG 并不理想,因为你只能创建静态、基于像素的路径,无法用 CSS 控制。因此我们将转而使用新的 shape() 函数,它可以定义复杂形状(包括曲线),并方便地用 CSS 控制。

本文只用到 shape() 的简单用法(只需要一条曲线),如果想深入了解这个强大函数,可以参考我之前的文章:

开始写代码

用到的 HTML:

<ul>
  <li>
    <a href="#"><span>A</span><span>b</span><span>o</span><span>u</span><span>t</span></a>
  </li>
  <!-- 更多 li 元素 -->
</ul>Code language: HTML, XML (xml)

CSS:

ul li a {
  display: flex;
  font-family: monospace;
}
ul li a span {
  offset-path: shape(???);
  offset-distance: ???;
}
ul li a:hover {
  offset-path: shape(???);
}Code language: CSS (css)

目前还比较朴素

CodePen Embed Fallback

用 flex 让字母并排,并用等宽字体,确保每个字母宽度一致。

接下来用下面的代码定义路径:

offset-path: shape(from Xa Ya, curve to Xb Yb with Xc Yc / Xd Yd );Code language: CSS (css)

这里用 curve 命令在 A 到 B 之间画贝塞尔曲线,控制点为 C 和 D。

然后通过调整控制点的坐标(尤其是 Y 值)来驱动曲线动画。当 Y 与 A、B 的 Y 相同时是直线;更大时变成曲线。

曲线的代码大致如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y1 / Xd Y1);

直线的代码如下:

offset-path: shape(from Xa Y, curve to Xb Y with Xc Y / Xd Y);

注意我们只改控制点的 Y,其他保持不变。

现在来确定各参数。使用 offset 时有两个要点:

  1. 默认以元素中心作为在路径上的位置。
  2. 定义在子元素上,但参考框是父容器。

第一个字母应在路径起点,最后一个在终点,所以 A 是第一个字母中心,B 是最后一个字母中心:

Y = 50%Xa = .5chXb = 100% - Xa = 100% - .5ch

C 和 D 的 X 没有固定规则,可以任意指定。我选 Xc = 30%Xd = 100% - Xc = 70%。你可以自己调整这些值试验不同的曲线形态。

路径现在可以这样写:

offset-path: shape(from .5ch 50%, curve to calc(100% - .5ch) 50% with 30% Y / 70% Y);

Y 是变量,可以是 50%(与 A、B 相同)或别的值,我们设成 50% - HH 越大,弹性越强。

试试看:

CodePen Embed Fallback

一团糟!因为我们没定义 offset-distance,所有字母都叠在一起了。

是不是要给每个字母单独设位置?那太麻烦了。

我们必须给每个字母不同的位置,好在可以用一个公式配合 sibling-index()sibling-count() 搞定。

第一个字母在 0%,最后一个在 100%。共 N 个字母,步长为 100%/(N - 1),字母从 0%100% 依次排布,公式为:

offset-distance: (100% * i)/(N - 1)

其中 i 从 0 开始。

写成 CSS:

offset-distance: calc(100%*(sibling-index() - 1)/(sibling-count() - 1))Code language: CSS (css)

CodePen Embed Fallback

几乎完美。除了最后一个字母外都位置正确。由于某种原因,0%100% 被当成同一个点。offset-distance 不限于 0%–100%,可以取任意值(包括负值),有一种取模行为形成环路。你可以从 0%100% 走完整条路径,到 100% 后又回到起点,还能继续从 100%200%,如此往复。

虽然有点反直觉,但修复很简单:把 100% 换成 99.9%。有点 hack,但有效!

CodePen Embed Fallback

现在排布完美了,悬停时可以看到直线变成曲线的过程。

最后加上 transition,就大功告成!

CodePen Embed Fallback

可能还不算完全搞定,因为动画似乎有些异常。这很可能是 bug(我已在此提交),不过问题不大,因为我本来就打算重构,避免重复写两次 shape,改为动画一个变量:

@property --_s {
  syntax: "<number>";
  initial-value: 0;
  inherits: true;
}
ul li a {
  --h: 20px; /* 控制效果强度 */
 
  display: flex;
  font: bold 40px monospace;
  transition: --_s .3s;
}
ul li a:hover {
  --_s: 1;
}
ul li a span {
  offset-path: 
    shape(
      from .5ch 50%, curve to calc(100% - .5ch) 50% 
      with 30% calc(50% - var(--_s)*var(--h)) / 70% calc(50% - var(--_s)*var(--h))
    );
  offset-distance: calc(99.9%*(sibling-index() - 1)/(sibling-count() - 1));
}Code language: CSS (css)

现在有了 --h 变量来调节路径曲率,以及一个内部变量在 0 到 1 之间动画,实现从直线到曲线的过渡。

CodePen Embed Fallback

嗒哒!动画完美了!但弹性感呢?

要得到弹性效果,需要调整缓动,用到 linear()。这是最简单的部分,我用生成器生成取值。

多调几次直到满意。我得到的是:

CodePen Embed Fallback

效果已经不错,但如果微调曲线还能更好。目前所有单词的曲线「高度」是一样的,理想情况是根据单词长度变化。为此我会在公式里加入 sibling-count(),让单词越宽时高度越大。

CodePen Embed Fallback

让效果具备方向感知

效果已经可用,但既然做到这里,不妨再进一步:根据鼠标方向决定曲线向上还是向下。

向上的曲线已经通过 --_s: 1 实现:

ul li a:hover {
  --_s: 1;
}Code language: CSS (css)

若改为 -1,就得到向下的曲线:

CodePen Embed Fallback

现在需要把两种情况结合起来。从上方悬停时,使用向下曲线 --_s: -1;从下方悬停时,使用向上曲线 --_s: 1

首先给 li 加一个伪元素,填满上半部分并位于链接上方:

ul li {
  position: relative;
}
ul li:after {
  content: "";
  position: absolute;
  inset: 0 0 50%;
  cursor: pointer;
}Code language: CSS (css)

CodePen Embed Fallback

然后定义两个不同的选择器。当悬停伪元素时,相当于也悬停了 li,所以可以用:

ul li:hover a {
  --_s: -1;
}Code language: CSS (css)

悬停 a 时,同样会悬停 li,上面的规则也会生效。但若悬停的是伪元素,则没有悬停 a,因此可以用:

ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

有点绕?没关系,我们把两个选择器放在一起看:

ul li:hover a {
  --_s: -1;
}
ul li:has(a:hover) a {
  --_s: 1;
}Code language: CSS (css)

我们可以从上方(通过伪元素)或从下方(通过 a)悬停。前者会触发第一个选择器,因为我们在悬停 li,但不会触发第二个,因为 li「并没有悬停其 a」。当我们悬停 a 时,两个选择器都会触发,后者会胜出。

方向感知就这么实现了!

CodePen Embed Fallback

能用,但不如开头的演示那么流畅。当鼠标移动穿过整个元素时,会突然停止一个动画并切换到另一个。

可以调整伪元素的大小来改善。悬停时让它覆盖整个元素,这样就不会再触达下方的 a,第二个动画就不会触发。而悬停 a 时,把伪元素高度设为 0,就无法悬停它,从而不会触发第一个动画。

CodePen Embed Fallback

好多了!把伪元素设为透明,效果就很自然。

CodePen Embed Fallback

小结

希望你喜欢这次 CSS 小实验。再提醒一次:在项目中投入使用前请三思。这是一个很好的 demos 来了解 shape()linear()sibling-index() 等现代特性,但为这类效果牺牲可访问性并不值得。

Vue 底层原理 & 新特性

作者 牛奶
2026年2月28日 23:43

Vue 底层原理 & 新特性

本文深入探讨 Vue 的底层架构演进、核心原理以及最新版本带来的突破性特性,面向面试和技术提升。


原文地址

墨渊书肆/Vue 底层原理 & 新特性


Vue 版本变动历史

Vue 自发布以来经历了多个重要版本的迭代,每个版本的改动都带来了架构优化和新特性,同时也伴随着一些 Breaking Changes。以下是 Vue 各个重要版本的变动概述:

Vue 2.0 (2016年)

  • 引入 Virtual DOMVue2 正式引入了虚拟 DOM,这是框架性能提升的关键技术。
  • 组件系统增强:增加了异步组件生命周期钩子调整等特性。
  • 支持 SSR:原生支持服务器端渲染,提升了 SEO 和首屏加载性能。
  • Vuex 与 Vue Router:作为官方解决方案提供状态管理路由管理

Vue 2.5 - 2.7 (2017-2022年)

  • Vue 2.5:改进了 TypeScript 支持,增强了响应式系统
  • Vue 2.6:引入了新的模板编译策略,插槽语法改进。
  • Vue 2.7:作为 Vue2 最后的大版本,引入了一些 Composition API 的向下兼容实现,为 Vue3 迁移做铺垫。

Vue 3.0 (2022年)

  • Composition API:引入了全新的组合式 API,提供了更灵活的逻辑组织方式。
  • Proxy 响应式系统:使用 Proxy 替代 Object.defineProperty,解决了 Vue2 响应式的诸多痛点。
  • Teleport & Fragments:新增内置组件,支持跨 DOM 层级渲染和多根节点模板。
  • 性能提升:更快的解析速度和更小的运行时体积,渲染性能提升约 100%。
  • 更好的 TypeScript 支持:原生支持 TypeScript,类型推导更加完善。
  • 自定义渲染器 API:增强的渲染器 API,便于跨平台开发。

Vue 3.1 - 3.4 (2023-2024年)

  • Vue 3.1:引入了 defineOptions 宏,改进编译优化。
  • Vue 3.3:进一步改进宏支持,类型化 props/emits 更加方便,简化了泛型组件的使用。
  • Vue 3.4:性能进一步提升,响应式系统优化,编译器效率改进。

Vue 3.5 及未来 (2024-2025年)

  • Vue 3.5:引入了响应式解构语法(Reactivity Transform),改善了大型应用的开发体验。
  • Vapor ModeVue 团队正在实验的全新渲染策略,跳过虚拟 DOM直接生成高效的 JavaScript 代码。
  • 更完善的生态集成:与 Vite 5PiniaVue Router 4 的深度整合。

响应式原理深度解析

响应式系统是 Vue 的核心,也是面试中的高频考点。Vue2 和 Vue3 在响应式实现上有着本质的区别。

Vue2:Object.defineProperty

Vue2 使用 Object.defineProperty 来劫持数据的 getter 和 setter:

function defineReactive(obj, key, val) {
  // 为每个属性创建 Dep 实例
  const dep = new Dep()
  
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      // 依赖收集
      if (Dep.target) {
        dep.depend()
      }
      return val
    },
    set: function reactiveSetter(newVal) {
      if (newVal === val) return
      // 通知更新
      dep.notify()
    }
  })
}

Vue2 响应式的局限性

  1. 无法检测对象属性的添加/删除Object.defineProperty 只能劫持已存在的属性,对于新增属性无能为力。
  2. 数组操作无法响应:通过下标修改数组元素 arr[0] = value 不会触发更新。
  3. 深层监听需要递归:对深层对象的监听会带来性能开销。

解决方案:Vue2 提供了 Vue.set / Vue.delete 以及重写数组方法来应对这些场景。

Vue3:Proxy

Vue3 使用 ES6 的 Proxy 来实现响应式:

function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      // 依赖收集
      track(target, key)
      const result = Reflect.get(target, key, receiver)
      // 如果是对象,递归代理实现深层响应式
      return isObject(result) ? reactive(result) : result
    },
    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
    }
  })
}

Vue3 响应式的优势

  1. 原生支持属性增删:Proxy 可以拦截对象的所有操作,包括新增和删除属性。
  2. 数组操作完全响应:下标赋值、数组长度变化等都能被正确拦截。
  3. 更好的性能:Proxy 是懒执行的,只有当访问属性时才会进行依赖收集。
  4. API 统一:ref 和 reactive 内部实现统一,简化了学习成本。

依赖收集与触发机制

Vue 的响应式系统遵循观察者模式,包含三个核心角色:

  1. Observer(观察者):负责劫持数据,收集依赖。
  2. Dep(依赖管理器):存储依赖,管理订阅者。
  3. Watcher(订阅者):在数据变化时执行更新回调。
// Dep 实现
class Dep {
  constructor() {
    this.subs = new Set() // 存储 Watcher
  }
  
  depend() {
    if (Dep.target) {
      this.subs.add(Dep.target)
    }
  }
  
  notify() {
    this.subs.forEach(watcher => watcher.update())
  }
}

// Watcher 实现
class Watcher {
  constructor(fn) {
    this.getter = fn
    this.value = this.get()
  }
  
  get() {
    Dep.target = this
    const value = this.getter()
    Dep.target = null
    return value
  }
  
  update() {
    this.value = this.getter()
  }
}

模板编译原理

Vue 的模板编译是将模板字符串转换为可执行渲染函数的过程,主要分为三个阶段。

1. 解析阶段(Parse)

将模板字符串解析为 AST(抽象语法树):

// 模板
<div class="container">
  <h1>{{ title }}</h1>
</div>

// AST 结构
{
  type: 'Element',
  tag: 'div',
  props: [{ type: 'Attribute', name: 'class', value: 'container' }],
  children: [
    {
      type: 'Element',
      tag: 'h1',
      children: [{ type: 'Interpolation', content: { expression: 'title' } }]
    }
  ]
}

2. 优化阶段(Optimize)

Vue3 的编译器会进行静态节点提升(Static Hoisting):

  • 静态节点:不包含任何响应式依赖的节点(如纯文本、静态属性)。
  • 事件缓存:对于不响应式变化的事件处理函数,进行缓存处理。
// 优化前
render() {
  return h('button', { onClick: this.handleClick }, 'Click')
}

// 优化后 - 事件函数被缓存
const handleClick = this.handleClick
render() {
  return h('button', { onClick: handleClick }, 'Click')
}

3. 代码生成阶段(Generate)

将 AST 转换为渲染函数:

// 生成的渲染函数
function render() {
  return _vue.createVNode('div', { class: 'container' }, [
    _vue.createVNode('h1', null, _vue.toDisplayString(this.title))
  ])
}

虚拟 DOM 与 Diff 算法

虚拟 DOM 的本质

虚拟 DOM 是真实 DOM 的 JavaScript 对象表示:

// VNode 结构
const vnode = {
  type: 'div',
  props: { class: 'container' },
  children: [
    { type: 'h1', children: 'Hello' }
  ],
  el: null // 关联的真实 DOM 引用
}

虚拟 DOM 的优势

  1. 跨平台渲染:同一套 VNode 结构可以渲染到不同平台。
  2. 减少 DOM 操作:在内存中进行对比,只更新必要的真实 DOM。
  3. 声明式开发:开发者只需关注数据变化,框架自动处理 DOM 更新。

Vue2 Diff:单端比较

Vue2 采用传统的 Diff 算法,从左到右依次对比:

function updateChildren(oldChildren, newChildren) {
  let oldStartIndex = 0
  let newStartIndex = 0
  let oldEndIndex = oldChildren.length - 1
  let newEndIndex = newChildren.length - 1
  
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 简单比较...O(n) 复杂度
  }
}

Vue3 Diff:双端比较 + 最长递增子序列

Vue3 采用了更高效的 Diff 算法

  1. 双端比较:同时从新旧列表的首尾进行对比。
  2. key 映射:通过 Map 快速定位相同 key 的节点。
  3. 最长递增子序列:对于需要移动的节点,使用 LIS 算法最小化移动次数。
// Vue3 Diff 核心逻辑
function diffChildren(n1, n2, parent) {
  const c1 = n1.children
  const c2 = n2.children
  const oldStart = 0
  const newStart = 0
  const oldEnd = c1.length - 1
  const newEnd = c2.length - 1
  
  // 双端比较策略
  while (oldStart <= oldEnd && newStart <= newEnd) {
    if (c1[oldStart].key === c2[newStart].key) {
      // 节点相同,继续
      patch(c1[oldStart], c2[newStart], parent)
      oldStart++
      newStart++
    } else if (c1[oldEnd].key === c2[newEnd].key) {
      // 尾部匹配
      patch(c1[oldEnd], c2[newEnd], parent)
      oldEnd--
      newEnd--
    }
    // ... 更多比较策略
  }
}

组件生命周期与更新机制

Vue2 生命周期

阶段 钩子 说明
初始化 beforeCreate 实例刚创建,数据观测未完成
初始化 created 数据观测完成,DOM 未生成
挂载 beforeMount 模板编译完成,准备挂载
挂载 mounted DOM 挂载完成,可操作 DOM
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeDestroy 实例销毁前,可清理
销毁 destroyed 实例已销毁

Vue3 生命周期

Vue2 Vue3 (Composition API)
beforeCreate -
created -
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeDestroy onBeforeUnmount
destroyed onUnmounted

组件更新流程

数据变化 → 触发 setter → Dep 通知 Watcher → 
触发 update() → 重新执行 render() 生成新的 VNode → 
Diff 对比 → 更新真实 DOM

Vue3 新特性深度解析

1. Composition API

组合式 API 是 Vue3 最重要的变化,提供了更灵活的逻辑组织方式:

// setup 函数 - 组件逻辑入口
import { ref, computed, onMounted, watch } from 'vue'

export default {
  setup() {
    const count = ref(0)
    const doubled = computed(() => count.value * 2)
    
    function increment() {
      count.value++
    }
    
    onMounted(() => {
      console.log('Component mounted!')
    })
    
    watch(count, (newVal) => {
      console.log(`Count changed to ${newVal}`)
    })
    
    return { count, doubled, increment }
  }
}

ref vs reactive

  • ref:用于原始类型,创建包含 .value 的响应式对象。
  • reactive:用于对象,创建深层响应式对象。
import { ref, reactive } from 'vue'

const count = ref(0)        // 原始类型
const state = reactive({   // 对象类型
  user: { name: 'Vue' }
})

// 模板中自动解包
console.log(count.value)   // JS 中需要 .value
console.log(state.user)    // reactive 直接访问

2. Teleport

将组件渲染到指定 DOM 位置,常用于模态框:

<Teleport to="body">
  <div v-if="show" class="modal">
    <p>Modal Content</p>
  </div>
</Teleport>

3. Fragments

支持多根节点模板:

<!-- Vue3 允许 -->
<template>
  <div>A</div>
  <div>B</div>
</template>

4. Suspense

处理异步组件加载状态:

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

Vue vs React:核心差异对比

响应式实现

特性 Vue2 Vue3 React
原理 Object.defineProperty Proxy useState/useReducer
触发方式 自动 自动 手动调用 setState
数组响应 重写方法 Proxy 需使用 Immer 或 immutable
深层监听 递归 Proxy 懒加载 useEffect 依赖

模板 vs JSX

  • Vue:模板语法,HTML-like,学习成本低,编译器优化。
  • React:JSX,JavaScript 表达式,更灵活,但需要一定学习曲线。

状态管理

  • Vue:Pinia(推荐)或 Vuex,采用模块化设计。
  • React:Redux/Zustand/Jotai,函数式风格。

渲染性能

Vue3 由于模板编译优化和 Proxy 响应式,在大多数场景下性能优于 React。React 的优势在于 Fiber 架构带来的精细化控制和并发渲染能力。


性能优化策略

1. 渲染优化

// 使用 v-once 静态内容
<div v-once>{{ staticContent }}</div>

// 正确使用 key
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

// v-if vs v-show 选择
<div v-if="show">很少切换</div>
<div v-show="show">频繁切换</div>

2. 响应式优化

import { shallowRef, markRaw } from 'vue'

// 浅层响应式 - 适合大型数据
const largeList = shallowRef([])

// 非响应式数据 - 适合不需要响应式的对象
const plainObj = markRaw({ /* ... */ })

3. 组件懒加载

// 路由懒加载
const Home = () => import('./views/Home.vue')

// 异步组件
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))

4. KeepAlive 缓存

<KeepAlive include="Home,About">
  <router-view />
</KeepAlive>

面试常见问题汇总

1. Vue2 和 Vue3 响应式的区别?

Vue2 使用 Object.defineProperty,需要递归监听所有属性,无法检测新增/删除属性;Vue3 使用 Proxy,原生支持属性增删,性能更好。

2. Vue 的依赖收集是如何实现的?

通过 Dep 类管理订阅者,Watcher 在读取响应式属性时将自身添加到 Dep,属性变化时 Dep 通知所有 Watcher 更新。

3. Vue3 Diff 算法相比 Vue2 有什么优化?

Vue3 采用 双端比较 策略,结合 最长递增子序列 算法,最小化 DOM 移动次数,复杂度从 O(n³) 优化到 O(n)。

4. Vue3 的 Composition API 有什么优势?

  • 更好的 TypeScript 支持
  • 代码更容易复用和抽取
  • 逻辑相关代码组织在一起,而不是按选项分散

5. Vue3 的性能为什么比 Vue2 好?

  • Proxy 替代 Object.defineProperty,深层监听懒执行
  • 模板编译优化:静态节点提升事件缓存
  • 优化的 Diff 算法
  • 更小的打包体积

6. Vue 的 nextTick 原理?

Vue 使用 Promise + MutationObserver + setTimeout 实现异步队列,在 DOM 更新后通过微任务执行回调。

7. keep-alive 的实现原理?

通过缓存 VNode,保存组件实例和状态,切换时复用而非重新创建。activated/deactivated 钩子用于感知缓存状态变化。

8. Vue 的模板编译过程?

解析优化(静态节点提升)→ 代码生成(渲染函数)


总结

Vue 作为一个渐进式框架,在保持易用性的同时不断深化底层技术的实现。Vue3 通过 Composition API、Proxy 响应式系统、优化的 Diff 算法等特性,显著提升了开发体验和运行性能。理解这些底层原理不仅有助于应对面试,更能在实际开发中做出更好的技术决策。

Vue 团队正在探索的 Vapor Mode 未来可能带来更大的性能突破,值得持续关注。

Vue 基础理论 & API 使用

作者 牛奶
2026年2月28日 23:41

Vue 基础理论 & API 使用

本文主要记录 Vue 的基础理论、核心概念与常用 API 使用方法,面向面试和日常开发参考。


原文地址

墨渊书肆/Vue 基础理论 & API 使用


Vue 简介

Vue 是一个渐进式 JavaScript 框架,由尤雨溪于 2014 年创建。Vue 核心库聚焦于视图层,易于学习和集成,同时能够驱动复杂的单页应用程序(SPA)开发。

核心特点

  • 响应式数据绑定 (MVVM 模式)
  • 组件化开发
  • 虚拟 DOM
  • 指令系统
  • 渐进式架构

安装与项目创建

Vite(推荐)

# 创建 Vue3 项目
npm create vue@latest

# 或使用 Vite 直接创建
npm create vite@latest my-vue-app -- --template vue

Vue CLI

npm install -g @vue/cli
vue create my-project

基础指令

v-model 双向绑定

v-modelVue 中用于表单输入和数据双向绑定的核心指令,本质是 v-bind + v-on语法糖

基本用法

<input v-model="message">
<p>{{ message }}</p>

修饰符

修饰符 说明
.lazy 在 change 事件时更新,而非 input
.number 自动转换为数值
.trim 去除首尾空白

自定义 v-model(Vue 3.4+):

// 子组件
defineProps(['modelValue'])
defineEmits(['update:modelValue'])

// 父组件
<MyInput v-model:title="title" />

v-if / v-show 条件渲染

特性 v-if v-show
DOM 操作 创建/销毁 display: none
初始渲染 惰性 立即渲染
切换性能
适用场景 很少切换 频繁切换
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else>C</div>

v-for 列表渲染

<li v-for="(item, index) in items" :key="item.id">
  {{ index }} - {{ item.name }}
</li>

注意事项

  • 必须使用 :key 绑定唯一标识
  • 不建议使用数组索引作为 key
  • Vue2 中 v-for 优先级高于 v-if,Vue3 中相反

v-bind / v-on 属性与事件

<!-- 绑定属性 -->
<img :src="url">

<!-- 绑定多个属性 -->
<img v-bind="attrs">

<!-- 事件监听 -->
<button @click="handleClick">Click</button>

<!-- 事件修饰符 -->
<button @click.stop="handle">阻止冒泡</button>
<button @click.prevent="handle">阻止默认行为</button>

组件选项

data

组件的响应式数据源,必须返回纯对象:

export default {
  data() {
    return {
      count: 0,
      user: { name: 'Vue' }
    }
  }
}

props

父子组件通信的重要方式,支持类型校验默认值

export default {
  props: {
    // 基础类型
    title: String,
    // 多个类型
    age: [Number, String],
    // 带默认值
    size: {
      type: String,
      default: 'medium'
    },
    // 必需
    id: {
      type: Number,
      required: true
    },
    // 自定义校验
    score: {
      validator: (value) => value >= 0 && value <= 100
    }
  }
}

Vue3 组合式 API

const props = defineProps({
  title: String,
  count: { type: Number, default: 0 }
})

computed 计算属性

缓存计算结果,只在依赖变化时重新计算:

export default {
  data() { return { count: 1 } },
  computed: {
    // 只读
    doubled() { return this.count * 2 },
    // 可写
    plusOne: {
      get() { return this.count + 1 },
      set(val) { this.count = val - 1 }
    }
  }
}

methods 方法

处理业务逻辑,每次渲染都会重新创建:

export default {
  methods: {
    handleClick() { /* ... */ }
  }
}

watch 监听器

监听数据变化并执行回调:

export default {
  data() { return { count: 0 } },
  watch: {
    count(newVal, oldVal) {
      console.log(`变化: ${oldVal}${newVal}`)
    },
    // 深度监听
    'obj.data': {
      handler() { /* ... */ },
      deep: true
    },
    // 立即执行
    name: {
      handler() { /* ... */ },
      immediate: true
    }
  }
}

Composition API

Vue3 引入的组合式 API,提供了更灵活的逻辑组织方式。

ref / reactive 响应式

import { ref, reactive } from 'vue'

// ref - 原始类型
const count = ref(0)
count.value++

// reactive - 对象
const state = reactive({
  user: { name: 'Vue' }
})
state.user.name = 'Vue3'

区别

| 特性 | ref | reactive | | ----- -| ----- | ---------- | | 适用类型 | 任意类型 | 对象/数组 | | 访问方式 | .value | 直接属性 | | 重新赋值 | 响应式 | 替换整个对象 |

toRefs / toRef

将 reactive 对象解构为独立的 ref:

import { reactive, toRefs } from 'vue'

const state = reactive({ name: 'Vue', age: 25 })
const { name, age } = toRefs(state)

// 或创建单个 ref
const nameRef = toRef(state, 'name')

computed() 计算属性

import { ref, computed } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)

watch / watchEffect

import { ref, watch, watchEffect } from 'vue'

// watch - 显式监听
watch(count, (newVal, oldVal) => { /* ... */ })
watch(() => state.name, (newVal) => { /* ... */ })

// watchEffect - 自动收集依赖
watchEffect(() => {
  console.log(count.value) // 自动追踪
})

执行时机控制

  • watch:默认同步执行
  • watchEffect:默认 pre(在组件更新前)
  • watchPostEffect:在组件更新后执行
  • watchSyncEffect:同步执行

生命周期钩子

import { 
  onMounted, 
  onUpdated, 
  onUnmounted 
} from 'vue'

export default {
  setup() {
    onMounted(() => { console.log('mounted') })
    onUpdated(() => { console.log('updated') })
    onUnmounted(() => { console.log('unmounted') })
  }
}

组件通信

Props / $emit

// 父组件
<Child :count="count" @update="handleUpdate" />

// 子组件
const props = defineProps({ count: Number })
const emit = defineEmits(['update'])
emit('update', props.count + 1)

Provide / Inject

祖先向后代跨级传值

// 祖先组件
provide('key', 'value')

// 后代组件
const value = inject('key')

响应式

// 祖先
const count = ref(0)
provide('count', count)

// 后代 - 修改会影响所有后代
const count = inject('count')

attrs/attrs / listeners

透传属性和事件:

<!-- 透传所有 -->
<Child v-bind="$attrs" v-on="$listeners" />

Pinia 状态管理

Vue3 推荐的状态管理方案:

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})

内置组件

Transition

为元素添加过渡动画

<Transition name="fade">
  <div v-if="show">Content</div>
</Transition>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}

过渡类名

  • v-enter-from / v-leave-from:起始状态
  • v-enter-active / v-leave-active:过渡中
  • v-enter-to / v-leave-to:结束状态

KeepAlive

缓存组件实例:

<KeepAlive include="A,B" exclude="C">
  <component :is="current" />
</KeepAlive>

生命周期

  • activated:激活时
  • deactivated:停用时

Teleport

渲染到指定 DOM 位置:

<Teleport to="#modal-root">
  <div class="modal">Content</div>
</Teleport>

Suspense

处理异步组件(实验性):

<Suspense>
  <template #default>
    <AsyncComponent />
  </template>
  <template #fallback>
    Loading...
  </template>
</Suspense>

生命周期

Options API

阶段 钩子 说明
初始化 beforeCreate 实例创建前
初始化 created 数据观测完成
挂载 beforeMount 模板编译完成
挂载 mounted DOM 挂载完成
更新 beforeUpdate 数据变化,DOM 未更新
更新 updated DOM 更新完成
销毁 beforeUnmount 实例销毁前
销毁 unmounted 实例已销毁

父子组件执行顺序

挂载:父 created → 子 created → 子 mounted → 父 mounted

更新:父 beforeUpdate → 子 beforeUpdate → 子 updated → 父 updated

销毁:父 beforeUnmount → 子 beforeUnmount → 子 unmounted → 父 unmounted


常用技巧

动态类名

<div :class="{ active: isActive, 'text-center': isCenter }">
<div :class="[activeClass, errorClass]">

条件类名

<div :class="[isActive && 'active']">

动态绑定 style

<div :style="{ color: textColor, fontSize: fontSize + 'px' }">

函数式组件

export default {
  functional: true,
  props: { msg: String },
  render(h, context) {
    return h('div', context.props.msg)
  }
}

异步组件

import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent({
  loader: () => import('./Async.vue'),
  loadingComponent: Loading,
  errorComponent: Error,
  delay: 200,
  timeout: 3000
})

面试常见问题

1. v-model 的原理?

本质是 v-bind:value + @input语法糖,监听 input 事件并更新数据。

2. v-for 中 key 的作用?

帮助 Vue 识别节点身份,实现高效的 DOM 复用。推荐使用数据唯一 ID,避免使用数组索引

3. computed 和 watch 的区别?

  • computed:计算属性,依赖变化自动计算,缓存结果
  • watch:监听器,监听数据变化,执行异步或复杂逻辑

4. Vue2 和 Vue3 的区别?

  • 响应式:Object.defineProperty → Proxy
  • API:Options API → Composition API
  • 多根节点:不支持 → 支持
  • 生命周期:beforeDestroy → beforeUnmount

5. 组件通信方式有哪些?

  • props / $emit:父子
  • provide / inject:祖先-后代
  • attrs/attrs / listeners:透传
  • 事件总线:兄弟/任意
  • Pinia/Vuex:全局状态

6. Vue 的响应式原理?

通过 Proxy/Object.defineProperty 劫持数据访问,在 getter 中收集依赖,setter 中触发更新


总结

Vue 以其简洁的 API 和渐进式的设计理念,成为前端开发的主流框架。掌握 Vue 的基础理论、常用 API 以及组件通信方式,是 Vue 开发者的必备技能。Vue3Composition API 提供了更现代化的开发范式,建议在实际项目中优先使用。

❌
❌