阅读视图

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

AI 页面与交互迁移流程参考

AI 页面与交互迁移流程参考

需求背景

要把 A 项目的父子账号逻辑,迁移到 B 里面,模仿 A 中的交互、页面设计,但是不是完全照抄代码。

这是个典型的 AI 需求,即不清晰,也不明确,一开始只有大致的思路,借助 AI 边做边改。最终细化上线的流程。这篇只展示前端的流程。

flowchart TD
A[需求] --> B[A 项目提取相关页面]
B --> C[B 项目产出改造设计]
C --> D{人工核对页面和交互}
D -- 不通过 --> C
D -- 通过 --> E[进入实现阶段]

用到的 AI 工具

  1. 编码工具:Code X
    用途:检索路由、页面入口、状态逻辑,生成映射和改造建议。

  2. Playwright MCP
    用途:按路由自动访问页面、截图、复现交互、沉淀证据。

流程步骤

步骤 1:在 A 项目提取页面样式和交互

flowchart TD
    Start[启动本地 A 项目] --> Config[将本地启动前缀配置给 Codex]
    Config -->|例如 http://localhost:3000| Command[给 Codex 下达提取指令]
    Command --> MCP[调用 Playwright MCP]
    MCP -->|自动搜索路由| Visit[访问所有父子账号相关页面]
    Visit --> Snap[页面自动截图并提取交互和样式]
    Snap --> Mark[在此基础上将需求点相关功能标注出来]
    Mark -->|推荐: PICGO+GitHub 作图床\n可选: 本地存储| Doc[整理并输出为 Markdown 文档]

步骤 2:在 B 项目产出改造设计

参考步骤 1 产出的 Markdown 文档,对比 A、B 项目,确定需要在 B 项目前端页面和交互上的改动点,并在图上和文字中做明确说明。本篇 Markdown 的核心作用即是展示后续在 B 项目前端页面和交互上的具体改造方案。

flowchart TD
    Doc1[参考步骤 1 输出的 Markdown 文档] --> Compare[对比分析项目 A 和项目 B]
    Compare --> Find[找出项目 B 前端和交互需要修改的地方]
    Find --> Mark[在页面截图上标出并用文字说明改动点]
    Mark --> DesignDoc[生成最终指导 B 项目改造的 Markdown 文档]

步骤 3:文档评审关口

将步骤 2 生成的改造设计文档交由外部专业角色(如产品经理、UI 设计师等)进行评审。这是一个循环验证过程,确保理解和设计完全对齐。

flowchart TD
    ReviewDoc[交付步骤 2 的需求和改造文档] --> Persons[产品和 UI 等专业人员进行审核]
    Persons --> Judge{是否审核通过?}
    Judge -- 否:存在问题 --> Modify[继续修改并对齐该文档]
    Modify --> ReviewDoc
    Judge -- 是:没有问题 --> Code[锁定设计,正式进入前端编码阶段]

全国铁路今天预计发送旅客1675万人次

记者从国铁集团获悉,今天(3月1日),全国铁路预计发送旅客1675万人次,计划加开旅客列车1036列。昨天(2月28日),全国铁路发送旅客1650.8万人次,连续9天保持在1500万人次以上,运输安全平稳有序。(央视新闻)

2026年汽车“国补”配套细则已在全国落地

马年伊始,多省份密集发布汽车以旧换新政策配套细则。据统计,2月10日以来,湖南省、青海省、河南省、福建省、江西省等超5个省份陆续出台汽车以旧换新配套细则。目前,中国内地各省份均已出台2026年汽车“国补”的配套细则。综合梳理,各省发布的补贴细则大致相同。政策实施时间均自2026年1月1日起,持续至2026年12月31日。(第一财经)

编程最强的模型,竟然变成了国产的它

f4bafcaa0f5b323a57ec23714eb0732d.jpg OpenRouter 是一个聚合了上百个模型的 API 调用平台,每周和每月会发布一次模型排行榜。

最近这个榜单的格局,变了。

ffe4b0322de8efcbf5cd39d588e3e83c.jpg

本月「模型排行榜」的前 10 名里,国产模型占了 4 席:
第 1 名 MiniMax M2.5(5.26T tokens)
第 2 名 Kimi K2.5(4.23T tokens,环比增长 5221%)
第 4 名 DeepSeek V3.2
第 8 名 GLM-5

90058b205e814a6daf95781fa28f7e93.jpg 而「编程排行榜」的前 10 名里,国产模型同样有 4 个:
第 1 名 MiniMax M2.5
第 2 名 GLM-5
第 4 名 MiniMax M2.1
第 5 名 Kimi K2.5

出乎意料的是,国产的 MiniMax M2.5 成为了本月 AI 模型榜单的整体第一名,包括编程领域!

它的核心优势有两点:

bfa1a95d6904d3e0398ea7dd488b1687.png

  1. 编程能力还不错:M2.5 的 SWE-Bench Verified 得分 80.2%,编程能力接近行业顶尖;Multi-SWE-Bench(多语言编程)达到 51.3%。端到端完成一个 SWE-Bench 任务只需 22.8 分钟,比 Claude Opus 4.6 的 22.9 分钟还快 0.1。

2.价格实惠:token 费用是 Claude Sonnet 4.6的六分之一左右。98/月可以满足常规使用,再不够还有其他档位。

让我欣喜的是,从中外模型格局上看,国产模型的编程能力已经逐渐进入国际前列,相信用不了多久,我们就可以打破「国外模型更好」的迷信,期待那一天!

你使用哪个模型更多?欢迎留言讨论~

# 我的 Agent 开发转型完整经历

机构:2月百城新建住宅销售均价环比下跌0.04%

中指研究院最新发布《中国房地产指数系统百城价格指数报告(2026年2月)》显示,2月百城销售均价:新建住宅环比下跌0.04%,同比上涨2.37%;二手住宅环比下跌0.54%,同比下跌8.78%;50城租赁均价:普通住宅环比下跌0.11%,同比下跌3.79%。(证券时报)

韩国2月出口超预期,连续第九个月增长

作为全球贸易晴雨表,亚洲第四大经济体韩国2月出口连续第九个月保持增长,尽管美国关税相关不确定性给前景蒙上阴影,但芯片热销持续支撑整体出口。贸易数据显示,韩国2月出口同比增长29.0%,至674.5亿美元,高于媒体调查经济学家预期的24.0%增幅中值。进口同比增长7.5%,低于调查预估的13.0%。2月初步贸易顺差为155.1亿美元。(新浪财经)

ArcGIS Pro 中的 Python 入门

^ 关注我,带你一起学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 数据集网站介绍

中国学者领衔提出规范使用抗菌药物的数字化方案

中国工程院院士钟南山领衔的国际团队近日在英国《自然-医学》杂志上发表论文,提出一种基层医疗机构抗菌药物管理的新方案。临床试验表明,该方案在未增加患者安全风险的基础上,可显著降低急性呼吸道感染的抗菌药物处方率。(新华社)

DDR4价格已连续11个月上涨

市场研究公司DRAMeXchange最新数据显示,2月份PC DRAM(DDR4 8Gb 1Gx8)产品的合约价格环比上涨13.04%,达到13美元。这是DDR4价格连续第11个月上涨。DDR4价格在一个季度内已翻了一番以上。(财联社)

理想汽车:2月交付新车26421辆

36氪获悉,理想汽车公布2026年2月交付数据。2026年2月,理想汽车交付新车26421辆。截至2026年2月28日,理想汽车历史累计交付量为1594304辆。

泰国黄金交易限制措施生效,当局力图遏制泰铢升值

泰国自3月1日起实施严格的零售黄金交易新规,旨在抑制投机行为。此类投机活动推高泰铢汇率,削弱了该国竞争力。据泰国央行官网公告,个人以泰铢计价的线上黄金交易,将实行每人、每平台每日5000万泰铢(约160万美元)的限额。以美元计价的交易、实体金店买卖以及期货市场不受此限。此外,央行要求所有交易必须全额预付电子支付,同时禁止代持账户与做空交易,称此举将提升市场透明度与规范水平。此举凸显出央行日益担忧:大额泰铢计价黄金交易放大了本币升值幅度。过去一年泰铢升值约9%,在媒体跟踪的亚洲货币中表现第二强。(新浪财经)

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

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

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

在 React 开发中,状态管理是一个绕不开的话题。随着应用规模的增长,组件之间的数据传递和状态同步会变得愈发复杂。如何选择一款合适的状态管理方案,直接影响着开发体验和代码可维护性。本文将带你梳理当前主流的状态管理方案,并重点推荐一套简洁高效的组合:Hox + ahooks

为什么需要状态管理

随着应用规模扩大,我们需要在不同组件之间共享数据。想象一下:用户在某个页面修改了头像,这个变化需要实时反映在导航栏、侧边栏等多个位置。如果没有统一的状态管理,就只能通过层层传递 props,代码冗余且难以维护。

状态管理有三个核心目标:统一管理——所有共享数据存储在可预测的位置;响应式更新——状态变化时依赖它的组件自动重渲染;可预测性——状态变化可控、可追踪。

React 中需要管理的状态分为三类:本地状态——组件内部 useState 管理,通常不跨组件共享;服务端状态——从 API 获取的数据,需要缓存、轮询等能力;全局状态——多组件共享的状态,如用户信息、主题、购物车等。

主流方案概览

React 生态中的状态管理方案百花齐放,各有各的设计哲学和适用场景。为了帮助大家建立一个整体认知,我们先来看一下主流方案的对比。

方案 学习成本 包体积 社区活跃度 适用场景
Redux Toolkit 中高 约12KB 非常高 大型企业级项目
Zustand 约1KB 中小型项目
Hox 约2KB 追求极简体验
useState + Context 0 内置 简单场景

从 npm 下载量来看,Redux 仍然占据主导地位,但 Zustand 的增长速度非常惊人,在 2024 年的 State of React 调研中,Zustand 的满意度和使用率都位居前列。值得注意的是,传统的 useState 配合 Context 仍然是很多开发者的首选,这说明在很多场景下,我们其实不需要引入额外的状态管理库。

Redux Toolkit

Redux 是 React 状态管理领域的鼻祖,核心理念是单向数据流和不可变状态。但原始 Redux 过度设计:繁琐的 action types、冗长的 reducers、重复的模板代码,让很多开发者望而却步。

Redux Toolkit 是官方推荐的新一代工具集,通过简化的 API 大大降低了门槛。核心包括:createSlice——在一个文件中定义 state、reducer 和 action;createAsyncThunk——处理异步逻辑;configureStore——简化 store 创建。

import { createSlice, configureStore } from '@reduxjs/toolkit'

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1 },
    decrement: (state) => { state.value -= 1 },
  },
})

const store = configureStore({ reducer: counterSlice.reducer })
export const { increment, decrement } = counterSlice.actions

组件中使用 useSelector 和 useDispatch:

import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement } from './store'

function Counter() {
  const count = useSelector((state) => state.counter.value)
  const dispatch = useDispatch()
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
    </div>
  )
}

Redux Toolkit 适合对状态管理有严格要求的大型项目,需要时间旅行调试、复杂中间件等高级特性。但如果项目规模不大,引入 Redux 可能带来不必要的复杂度。

Zustand + tanstack-query

Zustand 是一个轻量级的状态管理库,其名字在德语中意为“状态”。Zustand 的设计理念是极简主义,它没有 Redux 那么多约束性的概念,只需要几行代码就能创建一个可全局共享的状态。Zustand 使用 Hooks API 来创建和消费状态,这使得它与 React 的开发模式完美契合。

Zustand 的核心优势在于它的简洁性。创建一个 store 只需要调用 create 函数,传入一个返回状态和方法的函数即可。与 Redux 不同的是,Zustand 不需要 Provider 包裹,组件可以直接通过 Hook 来消费状态。这种设计大大减少了组件树的复杂度和不必要的重新渲染。

import { create } from 'zustand'

const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}))

function Counter() {
  const { count, increment, decrement } = useCounterStore()
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  )
}

而 tanstack-query(原 React Query)则是专门用于管理服务端状态的神器。它解决了数据获取、缓存、同步、轮询等一系列常见的后端数据管理需求。tanstack-query 的核心理念是将服务端数据视为一种特殊的“状态”,它应该独立于 UI 状态来管理。通过内置的缓存机制、后台刷新、乐观更新等功能,tanstack-query 大大简化了前后端数据交互的复杂度。

Zustand + tanstack-query 的组合在 2024 年备受推崇,这种组合兼顾了全局状态管理和服务端数据管理的需求,且两者都保持了极简的 API 设计。对于中型项目来说,这是一个性价比极高的选择。

其他方案

mobx、valtio、jotai,略。

为什么推荐 Hox + ahooks

在众多状态管理方案中,我想特别推荐 Hox + ahooks 这个组合。Hox 是一个专注于状态共享的轻量级库,而 ahooks 则是阿里巴巴开源的高质量 React Hooks 库。两者结合,能够提供一种极其简洁、直观的状态管理体验。

Hox 的核心理念是“状态即模型,模型即 Hook”。这意味着你可以用编写普通 React Hook 的方式来编写状态模型,不需要学习任何新的概念。在 Hox 中,创建一个全局状态与创建一个本地状态几乎没有区别,这大大降低了状态管理的复杂度。当你需要将一个组件的本地状态改为全局共享状态时,只需要将 useState 替换成 Hox 提供的 createGlobalStore 函数即可。

这种设计的优势是显而易见的。首先,它几乎不需要额外的学习成本,熟悉 React Hooks 的开发者可以立即上手;其次,它支持 TypeScript 类型自动推断,无需手动声明复杂类型;最后,它的 API 设计与 React 思维高度一致,不会产生心智负担。

ahooks 则是 React Hooks 工具库的佼佼者,它提供了大量实用的 Hook,覆盖了状态管理、DOM 操作、网络请求、传感器等众多场景。ahooks 的特点是高质量、可靠性强,由阿里巴巴前端团队维护,已在大量生产项目中得到验证。特别值得一提的是,ahooks 完美支持 SSR,这对于需要支持服务端渲染的项目来说是重要优势。

Hox + ahooks 的组合完美互补:Hox 解决全局状态共享问题,ahooks 则提供了丰富的工具 Hook 来处理各类复杂场景。从本质上讲,ahooks 解决的是“怎么做”的问题,而 Hox 解决的是“在哪存”的问题,两者配合使用,能够覆盖绝大多数前端状态管理需求。

Hox 核心用法

了解了 Hox 的设计理念,接下来我们深入探讨它的具体用法。Hox 的 API 设计非常简洁,只有两个核心函数:createGlobalStore 用于创建状态模型,useModel 用于在组件中消费状态。

安装

npm install hox
# 或
yarn add hox
# 或
pnpm add hox

创建状态模型

使用 Hox 创建一个全局状态模型非常简单,只需要调用 createGlobalStore 函数并传入一个返回状态和方法的函数即可。这个函数的写法与普通的自定义 Hook 完全一致,你可以在其中使用 useState、useReducer、useEffect 等任何 React Hooks。

// models/useCounter.js
import { createGlobalStore } from 'hox'
import { useState } from 'react'

export default createGlobalStore(function useCounter() {
  const [count, setCount] = useState(0)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(0)

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

这段代码看起来与普通的自定义 Hook 几乎一模一样,唯一的区别是使用了 createGlobalStore 函数进行包裹。createGlobalStore 函数会确保这个 Hook 返回的状态和方法能够在多个组件之间共享。

统一挂载全局状态

hox 的 createGlobalStore 生成的状态需要以组件的形式挂载的 react 树上。用 HoxRoot 包起来即可:

import { HoxRoot } from 'hox'

ReactDOM.render(
  <HoxRoot>
    <App />
  </HoxRoot>,
  domContainer
)

在组件中使用

在组件中使用 Hox 创建的状态同样简单,只需要导入对应的模型并调用即可:

import useCounter from '../models/useCounter'

function Counter() {
  const { count, increment, decrement, reset } = useCounter()

  return (
    <div>
      <h2>计数: {count}</h2>
      <button onClick={increment}>增加</button>
      <button onClick={decrement}>减少</button>
      <button onClick={reset}>重置</button>
    </div>
  )
}

可以看到,使用方式与普通 Hook 完全相同。Hox 会自动处理状态的共享和响应式更新,你不需要关心 Provider 的配置,也不需要担心状态泄漏到其他不相关的组件。

优化订阅

hox 的优化订阅是通过返回一个数组做浅比较,hook 本身还是全部返回的。

const { count } = useCounter(s => [s.count])

TypeScript 支持

Hox 对 TypeScript 提供了开箱即用的支持。当你使用 TypeScript 编写模型时,类型推断会自动完成,无需额外的类型声明:

// models/useCounter.ts
import { createGlobalStore } from 'hox'
import { useState } from 'react'

interface CounterState {
  count: number
  increment: () => void
  decrement: () => void
  reset: () => void
}

export default createGlobalStore<CounterState>(function useCounter() {
  const [count, setCount] = useState(0)

  const increment = () => setCount(c => c + 1)
  const decrement = () => setCount(c => c - 1)
  const reset = () => setCount(0)

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

不过,由于 Hox 采用了独特的类型推断机制,即使你不在 createGlobalStore 中显式声明类型,VS Code 等编辑器通常也能自动推断出正确的类型。

配合 ahooks 使用(可以是其他任意的 hook 库)

能够无痛复用各种 hooks 并将之于 store 整合在一起就是我认为 hox + ahooks 比 zustand + tanstack-query 要好的理由。 zustand 就没有这种类似的自由组合自定义 hook 的能力,相信我,在 zustand 里面实现请求的节流、防抖、轮询等逻辑就是噩梦,把请求放在 tanstack-query 这种不伦不类的用法真看不懂。

ahooks 是阿里巴巴开源的高质量 React Hooks 库,它提供了丰富的工具 Hook,能够与 Hox 形成完美的互补。ahooks 的特点是大而全、文档详细、质量可靠,已被大量国内外企业采用。

Hox 的一个重要优势是:你可以在全局状态模型中自由使用任何 React Hooks,包括 ahooks。这意味着你可以把 ahooks 的能力直接封装进全局状态,让状态管理模型更加强大。

useRequest:在 Hox 模型中管理网络请求

useRequest 是 ahooks 最核心的 Hook 之一,专门用于管理网络请求的状态。将 useRequest 融入 Hox 模型,可以轻松实现数据获取、轮询、缓存、防抖等功能:

// models/useUser.js
import { createGlobalStore } from 'hox'
import { useRequest } from 'ahooks'

export default createGlobalStore(function useUser() {
  const { data, loading, error, run, refresh, mutate } = useRequest(
    (userId) => fetch(`/api/users/${userId}`).then(res => res.json()),
    {
      manual: false,
      defaultParams: [1],
    }
  )

  const updateUser = async (userId, updates) => {
    const originalData = data.value
  
    mutate((currentData) => ({
      ...currentData,
      ...updates
    }))

    try {
      await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates)
      })
      refresh()
    } catch (err) {
      mutate(() => originalData)
    }
  }

  return {
    user: data,
    loading,
    error,
    refresh,
    updateUser,
  }
})

这样一来,使用该状态的组件只需要调用 useUser() 即可自动获得请求状态,无需在每个组件中重复编写请求逻辑。

useRequest 的配置项非常丰富:pollingInterval 可以设置轮询间隔,实现定时刷新;debounceInterval 可以将请求防抖处理,避免频繁请求;refreshOnWindowFocus 可以在窗口重新获得焦点时自动刷新数据;cacheKey 和 cacheTime 则提供了数据缓存能力。

useDebounce:在 Hox 模型中处理防抖

对于需要防抖或节流的场景,可以在 Hox 模型中直接使用 ahooks:

// models/useSearch.js
import { createGlobalStore } from 'hox'
import { useState, useEffect } from 'react'
import { useDebounce } from 'ahooks'

export default createGlobalStore(function useSearch() {
  const [keyword, setKeyword] = useState('')
  const [results, setResults] = useState([])
  const debouncedKeyword = useDebounce(keyword, { wait: 500 })

  useEffect(() => {
    if (!debouncedKeyword) {
      setResults([])
      return
    }
  
    fetch(`/api/search?q=${debouncedKeyword}`)
      .then(res => res.json())
      .then(data => setResults(data))
  }, [debouncedKeyword])

  return {
    keyword,
    setKeyword,
    results,
  }
})

useLocalStorageState:在 Hox 模型中持久化状态

如果需要将状态持久化到 localStorage,useLocalStorageState 提供了优雅的解决方案:

// models/useSettings.js
import { createGlobalStore } from 'hox'
import { useLocalStorageState } from 'ahooks'

export default createGlobalStore(function useSettings() {
  const [theme, setTheme] = useLocalStorageState('app-theme', 'light')
  const [language, setLanguage] = useLocalStorageState('app-language', 'zh-CN')

  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light')
  }

  return {
    theme,
    language,
    setTheme,
    setLanguage,
    toggleTheme,
  }
})

这个 Hook 会自动处理序列化、反序列化,以及跨标签页同步等细节。组件中使用时:

import useSettings from '../models/useSettings'

function ThemeToggle() {
  const { theme, toggleTheme } = useSettings()
  
  return (
    <button onClick={toggleTheme}>
      当前主题: {theme}
    </button>
  )
}

这些 Hook 大多数都是独立的,可以直接与 Hox 或其他状态管理方案配合使用。ahooks 的设计理念是“即插即用”,你不需要为了使用某个 Hook 而引入整个库,可以按需导入。

适用场景

任何技术方案都有其适用范围,Hox + ahooks 也不例外。理解这些方案的适用场景,能够帮助我们做出更明智的技术决策。

Hox + ahooks 最适合以下场景:首先是中小型项目,这类项目通常不需要复杂的状态架构,但仍然需要状态共享能力;其次是追求开发效率的团队,Hox 的学习曲线几乎为零,开发者可以立即投入生产;第三是对代码简洁性有要求的项目,Hox + ahooks 的组合代码量极小,可读性好;第四是需要快速迭代的项目,由于 Hox 的零心智负担特性,重构和调整都变得轻而易举;最后是个人项目或初创项目,这类场景通常追求快速上线而非长期可维护性。

对于大型企业级项目,Redux Toolkit 仍然是更稳妥的选择。Redux 的严格约束在大型团队中能够发挥优势:统一的状态结构使得代码审查更容易,强大的调试工具能够快速定位问题,完善的中间件生态能够满足各类扩展需求。虽然 Redux 的学习曲线较陡,但一旦团队掌握,往往能够保持较高的一致性。

总结

状态管理是 React 开发中的核心议题,选择合适的方案对项目成功至关重要。本文详细介绍了当前主流的状态管理方案:Redux Toolkit 适合大型项目和对规范性有高要求的团队;Zustand + tanstack-query 则是中型项目的热门选择,兼顾了简洁性和功能性;而 Hox + ahooks 的组合,以其极简的设计理念和零学习成本,成为中小型项目的理想选择。

Hox 的核心优势可以概括为三点:首先是简单,它使用与普通 Hook 完全一致的 API,不需要额外的概念;其次是直观,状态管理逻辑与组件逻辑写在同样的位置,代码可读性极高;第三是灵活,它既支持简单的全局状态,也能处理复杂的异步逻辑和副作用。

在实际项目中,我建议采用渐进式的技术选型策略:从小处着手,先使用本地状态和 Context 来解决简单需求;当发现状态开始变得难以管理时,再引入 Hox 来抽象全局状态;遇到复杂的网络请求场景时,补充 ahooks 的 useRequest。这种方式能够避免过早引入复杂性,让项目保持轻盈的同时具备扩展能力。

最后,技术的选择永远应该服务于业务需求。没有最好的方案,只有最适合的方案。希望本文能够帮助你更好地理解 React 状态管理的生态,并在实际项目中做出明智的技术决策。

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

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

以真实项目需求为背景,讲我们如何从「NPM 发包、iframe 子应用」的坑里走出来,用 Webpack 5 的模块联邦实现多应用运行时共享组件,并给出落地要点与避坑小结。


一、需求从哪来:多产品线要共用一套「家当」

我们这边有一条业务线,同时维护着中台配置端运营活动页数据看板 等多个独立前端应用。这些应用技术栈统一(React + Webpack),但各自独立仓库、独立部署。产品希望:设计系统里的按钮、表格、图表组件能在各应用里共用,且改一处、处处生效,而不是每个项目 copy 一份或各维护各的。

换句话说:多应用共享组件成了刚需,而且要尽量少耦合发版节奏——中台发版不能绑死活动页的发版。


二、我们试过的方案与痛点

在接触模块联邦之前,我们试过两种常见做法,都遇到了明显的瓶颈。

2.1 方案一:NPM 发包

把设计系统打成 @company/design-system 发到内网 NPM,各应用 npm install 后按需引用。

痛点

  • 版本强耦合:设计系统修个 bug 或加个组件,要发一版 NPM,各应用再升级依赖、再构建发布,链条长、节奏难对齐。
  • 多应用不同步:有的应用还在用旧版,有的已升级,线上会同时存在多版本,排查问题时要先看「当前应用装的是哪一版」。
  • 发版心理负担:小改动也要走发包流程,大家更倾向于在业务项目里 copy 一份改,时间一长又变成多份实现。

2.2 方案二:iframe 嵌子应用

把「组件展示页」做成独立应用,主应用用 iframe 嵌进去。

痛点

  • 隔离过重:样式、主题、路由、登录态都要额外打通,通信靠 postMessage,心智负担大。
  • 体验和性能:多一层 iframe,布局、滚动、弹窗都要特殊处理;首屏多一次文档加载,观感上也容易「慢一截」。
  • 不适合「组件级」复用:我们更需要的是「在页面里嵌一个按钮、一个图表」,而不是「嵌一整页」,iframe 更适合整页级的隔离。

这两条路走下来,我们意识到:需要一种运行时按需拉取、独立构建部署、又能像本地模块一样用的机制。后来在 Webpack 5 的文档里看到了 Module Federation(模块联邦),和我们要的场景非常契合。


三、模块联邦是什么:一句话 + 三个角色

模块联邦是 Webpack 5 内置的能力,让多个独立构建、独立部署的应用,在运行时像用本地模块一样加载彼此的代码。不用先发 NPM、不用 npm install,只要构建时配置好「谁暴露、谁消费」,运行时就能动态拉取并执行。

三个角色可以这么记:

角色 做什么
Remote(远程应用) 通过 exposes 把组件/模块暴露出去,打包出 remoteEntry.js,供别人加载。
Host(宿主应用) 通过 remotes 配置 Remote 的入口地址,用 import('remoteName/Button') 消费。
shared 双方声明共享依赖(如 React),可配 singleton: true,保证只加载一份,避免多实例冲突。

我们落地的形态是:设计系统单独一个应用作为 Remote,打包并部署 remoteEntry.js中台、活动页、看板等作为 Host,在需要的地方 import('designSystem/Button'),运行时从 CDN 拉取设计系统的 chunk,和本地代码一起跑在同一页面里。


四、我们怎么落地的:配置要点与坑

4.1 Remote 侧:暴露入口与 shared

设计系统项目里用 ModuleFederationPlugin 暴露组件,并和 Host 约定好 shared(React、ReactDOM 等)版本一致且设为单例,否则容易出现「Invalid hook call」之类的问题。

const { ModuleFederationPlugin } = require('webpack').container;

// Remote 的 webpack 配置片段
new ModuleFederationPlugin({
    name: 'designSystem',
    filename: 'remoteEntry.js',
    exposes: {
        './Button': './src/Button.jsx',
        './Table': './src/Table.jsx',
    },
    shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
    },
});

注意output.publicPath 必须能让 Host 正确拼出所有 chunk 的完整 URL(我们生产环境用 CDN 域名),否则运行时会 404。

4.2 Host 侧:配置 remotes 与动态加载

各业务应用在 Webpack 里配置 remotes 指向设计系统的 remoteEntry.js 地址(开发环境用本机或内网地址,生产用 CDN),然后用 React.lazy + Suspense 加载远程组件,对业务代码来说就像在用异步组件。

// Host 的 webpack 配置片段
new ModuleFederationPlugin({
    name: 'hostApp',
    remotes: {
        designSystem: 'designSystem@https://cdn.example.com/design-system/remoteEntry.js',
    },
    shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
    },
});
// 业务里使用
const RemoteButton = lazy(() => import('designSystem/Button'));

<Suspense fallback={<Spin />}>
    <RemoteButton>来自设计系统</RemoteButton>
</Suspense>

4.3 我们踩过的坑

  • publicPath:Remote 上线后若没配对,Host 拉 chunk 会 404,我们是在 CI 里把 Remote 的 publicPath 打成当前 CDN 前缀。
  • shared 版本:Host 和 Remote 的 requiredVersion 要兼容,否则可能加载两份 React,导致 hook 报错;我们统一用 ^18.0.0 并锁大版本。
  • CORS:Remote 的静态资源要允许业务域名的 origin,我们 Nginx 里对 remoteEntry.js 和 chunk 加了对应 Access-Control-Allow-Origin

五、结果与小结

落地模块联邦之后:设计系统单独发版、单独部署,各业务应用不用改依赖、不用重新装包,刷新页面即可拿到最新组件;多应用共享组件、发版解耦这两个目标都满足了。后续我们也在部分场景下用同一套机制做了「活动页作为 Remote、中台作为 Host」的集成,实现了一应用既可当 Host 也可当 Remote。

小结几句

  • 需求驱动:多应用共享组件、又要独立发版时,NPM 发包和 iframe 各有短板,模块联邦的「运行时拉取 + 独立构建部署」很贴这类场景。
  • 核心三件套:Remote 用 exposes 暴露并产出 remoteEntry.js,Host 用 remotes 拉取并用 import('remote/xx') 消费,shared 配成单例避免多实例。
  • 落地注意:publicPath、shared 版本、CORS 三点配好,再配合 CDN 和 CI,线上就能稳定跑。

如果你也在做多应用组件共享或微前端选型,希望这篇「从需求到落地」的小史能给你一点参考。更细的配置与手把手 Demo 可以看 Webpack 官方 Module Federation 文档module-federation-examples。觉得有用的话,欢迎点赞、收藏或评论区聊聊你的场景。

【Three.js内存管理】那些你以为释放了,其实还在占着的资源

前言

你以为你 dispose 了,它就没了吗?Too young ~

三个月前,我差点被一个 Bug 搞到怀疑人生。

事情是这样的:我负责的一个智慧园区项目,上线前测试同学跑过来说:“页面打开久了会卡,你瞅瞅?”

我打开页面,刚开始确实丝滑,60fps 稳稳的。然后我开始疯狂切页面、关弹窗、加载新模型……五分钟后再看帧率,30fps。十分钟后,15fps。二十分钟后,页面直接白屏,Chrome 弹出一个熟悉的提示:

“喔唁,崩溃啦。”

我懵了。

代码里明明写了 dispose(),该释放的都释放了,怎么还能崩?打开 Chrome 任务管理器一看,GPU 内存那一栏的数字,像坐了火箭一样往上涨,根本停不下来。

那天下午,我干了一件事:把所有以为释放了、实际还占着的资源,一个个揪出来。今天就把这些“装死”的资源全曝光,省得你们也踩坑。


第一个坑:几何体,你 dispose 了吗?

先看一段我当时的代码:

// 加载一个模型
loader.load('big-model.glb', (gltf) => {
  const model = gltf.scene;
  scene.add(model);
});

// 后来某个时刻,移除模型
scene.remove(model);
// 心想:移除就完事儿了,内存会自动释放吧?

天真的我,以为 remove 就万事大吉。结果呢?几何体数据还赖在 GPU 里不走。

正确做法

// 移除前,遍历模型,dispose 所有几何体和材质
function disposeModel(model) {
  model.traverse((obj) => {
    if (obj.isMesh) {
      if (obj.geometry) {
        obj.geometry.dispose();
      }
      if (obj.material) {
        if (Array.isArray(obj.material)) {
          obj.material.forEach(m => m.dispose());
        } else {
          obj.material.dispose();
        }
      }
    }
  });
  scene.remove(model);
}

你以为这就够了?太天真了。材质里的纹理呢?你不 dispose,它还在!


第二个坑:纹理,你 dispose 了吗?

材质 dispose 只会释放材质本身的 GPU 资源,但纹理是单独分配的。你得手动把纹理也干掉。

// 错误:只 dispose 材质
material.dispose(); // 纹理还在!

// 正确:先 dispose 纹理
if (material.map) material.map.dispose();
if (material.normalMap) material.normalMap.dispose();
if (material.roughnessMap) material.roughnessMap.dispose();
// ... 还有 aoMap、emissiveMap、metalnessMap ...
material.dispose();

有一次我忘了 dispose 纹理,结果加载了 100 个不同的模型,每个模型都带一张 4K 贴图。你们猜 GPU 内存用了多少?直接爆了,页面黑屏。

更坑的是,有些纹理是多个材质共用的。如果你 dispose 了共用纹理,其他材质也跟着完蛋。所以必须做好引用计数,或者用 ResourceTracker 统一管理。


第三个坑:RenderTarget,你不 dispose 试试?

做后期处理的时候,经常用到 WebGLRenderTarget。比如 ping-pong buffer、阴影贴图、反射纹理……

const rt = new THREE.WebGLRenderTarget(1024, 1024);
// 用完之后,忘了 dispose

这个玩意儿,不 dispose 的话,显存占用一直不释放。而且你肉眼看不见,Chrome 任务管理器里 GPU 内存悄悄上涨。

正确:用完就扔。

rt.dispose();

特别是做动态效果,每帧新建一个 RenderTarget 又不释放,那内存涨得比股票还快。


第四个坑:InstancedMesh 的矩阵,你以为删了就没了?

InstancedMesh 是个好东西,能把成千上万个实例压缩成一个 Draw Call。但如果你动态增删实例,得小心。

// 创建
const instancedMesh = new THREE.InstancedMesh(geo, mat, 1000);
scene.add(instancedMesh);

// 后来想删掉一部分实例,直接把 count 改小?
instancedMesh.count = 500;
// 你以为剩下的 500 个实例的内存就释放了?

图样图森破InstancedMesh 内部的矩阵缓冲区(instanceMatrix)还是 1000 的大小,只是渲染时只画前 500 个。那 500 个被“删掉”的实例数据还占着显存。

正确做法:重新创建一个新的 InstancedMesh,只保留需要的数量。或者更狠一点,自己维护一个动态数组,每帧重新上传矩阵。


第五个坑:BufferAttribute,你 dispose 了吗?

有时候我们手动创建几何体:

const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([...]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

当你 geometry.dispose() 时,这些 BufferAttribute 也会被 dispose 吗?答案是:会,但前提是这些 attribute 没有被其他地方引用

如果你把同一个 BufferAttribute 赋值给两个几何体,dispose 其中一个,另一个的 attribute 还在,但底层 GPU 缓冲区可能已经被释放了,导致另一个几何体渲染出错。

所以,共用 attribute 要小心,要么就别共用,要么就用引用计数自己管理。


第六个坑:Texture 的 image,你还得手动 revoke?

如果你用 URL.createObjectURL() 加载图片,比如从本地文件上传生成纹理:

const url = URL.createObjectURL(file);
const texture = loader.load(url);
// 用完 texture 后,dispose 了纹理,但 URL 还没释放

URL.revokeObjectURL(url) 得自己调用,否则内存泄漏。而且这个泄漏不在 GPU,而在 JS 堆里,Chrome 任务管理器看不出来,但用久了页面一样卡。


第七个坑:动画混合器,你 stop 了吗?

如果你用了 AnimationMixer 播放动画,直接移除模型而不停止动画,mixer 内部还有对模型的引用,导致模型无法被垃圾回收。

// 错误
scene.remove(model);
// mixer 还在引用 model 内部的骨骼、材质等

// 正确
mixer.stopAllAction();
mixer.uncacheRoot(model); // 重要!
scene.remove(model);

这个坑我踩过,找了半天才发现 mixer 偷偷摸摸抱着模型不放手。


第八个坑:画布弹窗,我把每帧都变成了内存地雷

这事儿说起来有点丢人,但为了大伙儿不重蹈覆辙,我还是交代了吧。

去年做第一个正式项目,有个需求:点击设备,弹出一个悬浮面板,显示实时数据。当时我年轻气盛,心想这弹窗得跟3D场景“天衣无缝”啊,用普通的HTML div多掉价,飘在画布上面,一点儿都不酷。

于是我想了个自认为很牛的办法:用 Sprite + Konva

Konva 是个Canvas 2D库,可以在上面画各种UI元素。我把它画好的Canvas转成Three.js的 CanvasTexture,然后贴到 Sprite 材质上,再把 Sprite 放到3D空间里。完美!弹窗像模型一样存在于场景中,可以旋转、缩放,跟设备严丝合缝。

更让我得意的是,数据是实时更新的,比如温度、压力每秒都在变。我就写了个定时器,每秒重新画一次Konva画布,生成新的CanvasTexture,赋值给Sprite材质。

// 伪代码:每秒更新弹窗纹理
setInterval(() => {
  // 1. 清空Konva画布,重新画UI
  konvaLayer.clear();
  konvaLayer.draw();
  
  // 2. 把画布转成Three纹理
  const canvas = konvaLayer.toCanvas();
  const texture = new THREE.CanvasTexture(canvas);
  
  // 3. 赋给Sprite
  sprite.material.map = texture;
  sprite.material.needsUpdate = true;
}, 1000);

刚开始测试,一切正常,数据跳动,弹窗灵动,我美滋滋地交付了。

然后噩梦开始了。

上线第一天,现场反馈:系统运行四个小时左右就崩溃了。我远程一看,页面白屏,Chrome报错“Out of Memory”。打开任务管理器,GPU内存已经顶到2GB(我的笔记本才4GB)。

我第一反应:是不是Konva画布太大?压缩图片,降低分辨率,从512x512降到256x256。重新上线,六小时崩溃

我又想:是不是Canvas转纹理的时候没释放旧的?于是我加了一行:

if (sprite.material.map) sprite.material.map.dispose();

再上线,八小时崩溃

我开始怀疑人生了。不断优化,不断测试,内存泄漏的时间从四小时延长到十二小时、十八小时,但始终无法根除。最后,我把Konva换成原生Canvas画图,自己管理画布,甚至手动调用 canvas.width = canvas.width 来清空,二十小时崩溃一次

我盯着那个“二十小时崩溃”的数据,突然明白了一个道理:这条路,走不通

问题到底出在哪儿?

后来用Chrome Memory面板拍快照对比,发现罪魁祸首有三个:

  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 多相机渲染】如何在同一场景里实现“画中画”效果

❌