普通视图

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

Service Worker在电子菜单中的实际应用

2026年3月30日 13:32

一、分享背景与业务场景

针对门店菜单电子屏长时间开机、网络差、易白屏的痛点,通过 Service Worker 实现前端离线缓存与请求拦截,确保断网仍可正常展示菜单,提升顾客体验与门店运营稳定性,同时沉淀可复用的离线前端方案。

1.1 业务场景

菜单电子屏是一套面向门店的 Vue 3 前端应用,运行在店内大屏、平板等设备上,核心用于展示实时菜单、热销商品、多时段菜单等内容。

门店设备通常处于长时间开机状态,且网络环境不稳定,无法依赖稳定网络支撑应用运行,因此对离线可用缓存策略有强需求,需保障核心功能在断网 / 弱网下正常使用。


1.2 为什么使用Service Worker

Service Worker 是浏览器后台运行的代理脚本,独立于页面线程,具备拦截所有网络请求、管理本地缓存、实现离线业务逻辑的核心能力,其价值与菜单电子屏业务高度匹配。

离线可用:断网不影响核心功能

Service Worker 在安装阶段预缓存、首次加载后懒缓存菜单基础资源(Vue 框架、静态样式、菜单模板、默认菜品图片);当设备断网时,自动拦截网络请求,直接从本地缓存返回资源,确保菜单大屏仍展示最后一次同步的菜单数据,不出现白屏 / 报错。

业务价值:

门店断网、网络波动时,顾客仍可正常查看菜单、热销品等核心信息,不影响门店基础运营,提升顾客体验、降低运营投诉。


1.3 实际效果

因此,选用Service Worker 作为离线与缓存方案的核心,并结合业务做了策略细化与联动。


二、Service Worker 详解

2.1 什么是 Service Worker

Service Worker 基于浏览器独立线程运行,不与页面主线程耦合,遵循:install → activate → active 生命周期。

  • 通过浏览器原生 Cache API 管理本地缓存
  • 通过拦截 fetch 请求 实现请求重定向与缓存返回
  • 是前端 PWA 离线能力的核心技术

2.2 生命周期

Service Worker 从“被注册”到“真正接管页面请求”,会经历几个状态,理解它们有助于排查“为什么没生效”“为什么还是旧缓存”。

暂时无法在飞书文档外展示此内容

要点

  • Installing:脚本首次或更新后解析成功,触发 install。这里常做“预缓存”(把关键 HTML/JS/CSS 提前放进 Cache)。
  • Waiting:如果当前已有旧版 SW 在控制页面,新 SW 会停在这里,直到旧 SW 控制的页面全部关闭,或主线程调用了 skipWaiting()
  • Activating:新 SW 开始接管,触发 activate。这里常做“清理旧版本用的 Cache 名字”。
  • Activated:之后页面发出的、落在 SW 作用域内的 fetch 才会被 fetch 事件拦截。
  • Idle / Terminated:一段时间没有 fetch,浏览器可能把 SW 挂起或终止,下次有请求再拉起来。

我们项目里用了 skipWaiting: trueclientsClaim: true,所以新 SW 会尽快激活并立刻接管所有客户端,不用等用户关掉所有标签页。


2.3 作用域(scope)与注册

Service Worker 只对自己作用域下的请求生效。作用域由注册时传入的 path 决定,默认是 sw.js 所在目录。

  例如:

  • sw.jshttps://example.com/static/sw.js,默认 scope 为 https://example.com/static/
  • 只有该路径及其子路径下的页面(如 /static/app/)发出的请求会被这个 SW 拦截;/other/ 下的页面不会。

注册方式(主线程):

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js', { scope: '/' })
  .then(reg => console.log('SW 注册成功', reg.scope))
    .catch(err => console.error('SW 注册失败', err))
}

2.4 拦截请求:fetch 事件

简化版手写示例:
self.addEventListener('fetch', (event) => {
  const url = event.request.url
  // 只处理同源或指定的接口/资源
  if (!url.includes('/api/menu')) return
  event.respondWith(
    fetch(event.request)
      .then(res => {
        const clone = res.clone()
        caches.open('menu-api-cache').then(cache => cache.put(event.request, clone))
        return res
      })
      .catch(() => caches.match(event.request))
  )
}) 

2.5 Cache API 与 Cache Storage

Service Worker 用的“缓存”不是 localStorage,而是 Cache API(浏览器里常叫 Cache Storage)。

  • caches:全局对象,类似 caches.open('my-cache-name') 得到一个 Cache 对象。
  • 每个 Cache 里存的是 Request → Response 的键值对,键是请求对象,值是响应对象。
  • 同一个域名下可以有多个 Cache(例如我们项目里的 storemenu-api-cachestoremenu-pic-cachejs-cache),互不覆盖,便于按“接口 / 图片 / 静态资源”分开管理和过期。

常用方法:

 // 打开(或创建)一个命名缓存
const cache = await caches.open('storemenu-api-cache')

// 存:请求 + 响应
await cache.put(request, response)

// 取:只根据 request 查,返回 response 或 undefined
const response = await cache.match(request)

// 删
await cache.delete(request)

// 列出该 Cache 里所有 request
const keys = await cache.keys()

三、技术架构概览

3.1 技术栈与缓存策略

  • 构建: Vite 3
  • PWA/Service Worker: vite-plugin-pwa + Workbox(runtimeCaching)
  • 前端: Vue 3 + Pinia + Vue Router
  VitePWA({
      registerType: 'autoUpdate', //生成的注册脚本会自动检查SW更新,发现新版本时在后台下载并切换
      srcDir: './', //指定 Service Worker 源文件所在目录。
      filename: 'sw.js', //生成的 SW 文件名(最后会出现在站点根目录,例如 https://xxx/sw.js)。
      includeAssets: ['favicon.ico'],//指定额外要预缓存的静态资源(通常不在 Vite 打包产物里),比如站点图标。
      injectRegister: 'auto', //让插件 自动在入口文件里注入 SW 注册代码,不用你手写 navigator.serviceWorker.register(...)。
      workbox: {
        cacheId: 'E-menu-cache', //给当前这套 Service Worker 的缓存起一个“前缀 ID”。
        cleanupOutdatedCaches: true, //自动清理 已经不再被当前 SW 配置使用的旧 cache。
        skipWaiting: true, //对应生命周期里的 skipWaiting()。
        clientsClaim: true, //对应生命周期里的 clients.claim()。
        runtimeCaching: [
          {
            urlPattern: /.*/(storemenu-api).*/,//用正则或字符串匹配要处理的 URL
            handler: 'NetworkFirst', //使用的策略,如 'NetworkFirst'、'CacheFirst' 等
            options: {
              cacheName: 'storemenu-api-cache', //对应的 cache 名字
              cacheableResponse: {
                statuses: [200],//只有状态码在这个列表里的响应才会被写进缓存
              },
              expiration: {
                maxEntries: 1, // 只保留一份接口缓存
                maxAgeSeconds: 7 * 24 * 60 * 60, // 缓存7天
              },
            },
          },
        ],
      },
    }),

PWA(Progressive Web App)通常包含:

  • HTTPS 部署(SW 只工作在安全源下)
  • Service Worker 做离线与缓存
  • Web App Manifest(图标、名称、主题色、是否全屏等)

Service Worker 是 PWA 能“离线用、秒开”的核心;没有 SW,就只是普通网页。我们项目通过 vite-plugin-pwa 同时生成了 manifest 和基于 Workbox 的 sw.js,所以既满足“可安装到桌面”,又满足“菜单电子屏离线可用”。


3.2 常见缓存策略

策略本质是“先网络还是先缓存、失败时怎么回退”。下面用一句话 + 我们项目里的用法概括:

策略 逻辑(一句话) 典型用途 本项目
NetworkFirst 先请求网络,成功则返回并写入缓存;失败则用缓存 希望尽量新、又要离线兜底 菜单 API、图片、JS/CSS
CacheFirst 先查缓存,有则返回;没有再请求网络并写入缓存 版本化静态资源、不常变的图片 未用(图片我们改为 NetworkFirst 以便更新)
StaleWhileRevalidate 先返缓存(若有),同时后台请求网络,下次用新响应 首屏要快、数据可略旧一版 未用
NetworkOnly 只走网络,不写缓存 必须实时的接口 未用
CacheOnly 只读缓存,没有就失败 预缓存好的 App Shell 未用

我们清一色用 NetworkFirst,保证在线时拿到最新数据/图片,离线时再回退到 Cache,和“菜单要新、又要抗断网”的需求一致。


3.3 多时段菜单 + 图片预加载与离线切换

场景:早/午/晚等多时段菜单,每个时段有不同背景图。若只缓存“当前时段”的图,离线切换到其他时段会缺图。

做法

  1. 一次拉全量:菜单 API 返回所有时段的 menuTimePeriodDisplayVO,前端缓存在内存(如 cachedMenuData)。
  2. 预加载所有时段图片:在线拿到数据后,对每个时段的 backgroundUrl 以及公共图(如 discountImgUrlhotImgUrl 等)做预加载。
  3. 通过 fetch 写入 SW 缓存:预加载时用 new Image() 触发加载,再对同一 URL 执行 fetch(url, { cache: 'reload' }),让 SW 的 storemenu-pic-cache 写入该图片。
  4. 离线切换:时段切换仅改前端展示的数据和图片 URL,不再发请求;图片从 storemenu-pic-cache 读取,避免 no-response
// 预加载单张图片并触发 SW 缓存(先对比缓存,避免重复下载)
const cacheImage = async (imageUrl, imageName = '') => {
  const cachedUrl = await getCachedImageUrl(imageUrl) // 从 storemenu-pic-cache keys 对比
  if (cachedUrl === imageUrl) return

  const img = new Image()
  img.src = imageUrl
  img.onload = async () => {
    await fetch(imageUrl, { cache: 'reload' })  // 写入 storemenu-pic-cache
  }
}

// 预加载所有时段背景图 + 公共图
const preloadTimePeriodImages = async () => {
  // 公共图
  if (cachedMenuData.value?.discountImgUrl) await cacheImage(cachedMenuData.value.discountImgUrl, 'discountImg')
  // ...
  // 各时段背景图
  for (const menu of cachedMenuData.value.menuTimePeriodDisplayVO || []) {
    if (menu.backgroundUrl) await cacheImage(menu.backgroundUrl, `timePeriod_${menu.timePeriodId}`)
  }
}

作用

  • 预加载前用 getCachedImageUrl 对比当前缓存,避免重复下载。
  • 多时段数据 + 多时段图片全部进缓存,离线可按时段自动切换且无缺图。

3.4 资源缓存策略

目标:既保证离线可用,又避免无用图片长期占用(如门店更换菜单后旧图仍占缓存)。

实现

  1. SW 层storemenu-pic-cache 使用 expiration.maxEntriesmaxAgeSecondspurgeOnQuotaError 做自动淘汰。

  2. 应用层:在预加载或数据更新后,执行 cleanupUnusedImageCache()

    1. 根据当前 cachedMenuData 汇总“当前仍需要的图片 URL”(含各时段背景、公共图等)。
    2. 打开 storemenu-pic-cache,遍历 keys(),删除不在上述集合内且属于 storemenu-pic 的请求。
const cleanupUnusedImageCache = async () => {
  const currentImageUrls = new Set()
  // 收集 公共图 + 所有时段 backgroundUrl
  // ...
  const picCache = await caches.open('storemenu-pic-cache')
  const cachedRequests = await picCache.keys()
  for (const request of cachedRequests) {
    if (isStoremenuPic(request.url) && !currentImageUrls.has(request.url)) {
      await picCache.delete(request)
    }
  }
}

效果:缓存内容与当前菜单配置对齐,存储可控,离线仍能正常显示当前及多时段所需图片。

3.5调试与注意点

  • Chrome DevTools → Application → Service Workers:查看当前页面的 SW 状态(installing/waiting/activated)、可 Unregister、Update、勾选 Update on reload 方便开发时每次刷新都更新 SW。
  • Application → Cache Storage:看各 Cache 里的 Request/Response,可手动删某条或清空,用来验证“单份”“清理”是否生效。
  • Application → Storage → Clear site data:会清掉 SW 和所有 Cache,适合做“首次访问”测试。
  • 开发时若改了 vite.config.js 里 workbox 配置,需要重新 build 才会生成新的 sw.jsregisterType: 'autoUpdate' 会在下次打开页面时自动用新 SW。

四、整体请求与缓存流程

  1. 首次在线访问

    1. 页面加载 → SW 注册并接管 fetch。
    2. 请求菜单 API → SW NetworkFirst → 网络成功 → 写入 storemenu-api-cache(仅 1 条)。
    3. 前端保存 cachedMenuData,执行 cleanupApiCache + ensureApiCache(apiUrl)
    4. 若在线,执行 preloadTimePeriodImages(),所有时段背景图及公共图通过 fetch 进入 storemenu-pic-cache;再执行 cleanupUnusedImageCache()
  2. 在线再次访问 / 定时刷新

    1. 菜单 API 再次请求 → 网络成功 → SW 更新同一条 API 缓存;应用层再次清理并 ensure 当前 URL。
    2. 预加载与清理同上,保证缓存与当前配置一致。
  3. 离线访问

    1. 菜单 API 请求 → SW NetworkFirst 网络失败 → 从 storemenu-api-cache 返回唯一一条缓存。
    2. 前端用该数据填充 cachedMenuData,按当前时间筛选时段并展示;图片请求由 SW 从 storemenu-pic-cache 返回,多时段切换无需网络。

五、小结

在菜单电子屏项目中,Service Worker 不仅提供了离线可用能力,还通过 API 单份、多时段图片预加载、应用层 + SW 双端清理 等设计,在“数据尽量新”和“离线稳定展示”之间取得平衡,并把不同类型资源拆分到独立缓存桶中,同时缓存体量可控、不再无限膨胀。上述模式可直接复用到其他需要离线优先、多版本/多时段资源的大屏或 PWA 场景中。

《前端周刊》尤大官宣 Vite 8 稳定版首发!npm 新官网?React 官网更新。focusgroup 新功能!

作者 Web情报局
2026年3月30日 12:36

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • ⚡️ 尤大官宣 Vite 8,第一个 Rust 驱动的稳定版本正式发布
  • ✨ React 官网重写了 2 个 Hooks 文档,我学不动了
  • 🤝 谷歌诚邀大家测评 focusgroup,“后 HTML5 时代“的新功能
  • 📦 全新的 npmx 注册源网站上线,npm 官网惨遭“降维打击“

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 每周热搜

Rolldown + Vite 稳定版首发

Vite 是 GitHub 第一 构建工具,提供了基于原生 ESM 模块的开发服务器,和打包优化的构建功能。

Vite 团队官宣 Vite 8 正式发布,这是 第一个 使用 Rust 打包器 Rolldown 驱动的 稳定版本,标志着 Rust 催生的“前端工业革命“日益流行。

Vite 8 除了把 esbuild + rollup 换用为 Oxc + Rolldown,该主版本还有下列亮点。

首先是开发体验,新增 Vite Devtools 开发工具,可以直接从开发服务器调试;支持浏览器控制台转发到 CLI 终端,方便 AI 分析。

Vite Devtools

然后,内置了 TypeScript 功能,支持 tsconfig path,解析路径别名;支持 emitDecoratorMetadata 选项,无需安装外部插件。

此外,@vitejs/plugin-react v6 主版本更新,集成 Oxc,不再依赖 Babel;.wasm?init 导入可以在 SSR 环境运行。

最后,新增 Vite Plugin 插件官网,方便大家搜索 Vite 生态的插件。

vite-plugin.png

Vite 8 之前,为了避免反复造轮子,Vite 开发时使用 esbuild 快速编译,构建时使用 rollup 打包优化,但双打包机制“天衣有缝“,两种流程始终存在 不一致性

为此,尤大组建 Rolldown 团队,换用 Rust 编写的 Rolldown 统一流程,它既兼容 Rollup 插件 API,又媲美 esbuild 的性能,构建速度暴涨 10 倍。

直至今日,我们终于拥有基于 Rust 的打包神器!

感谢尤大和 Vite 团队,感谢 esbuild、rollup 和所有社区贡献者~

🛜 官方情报

Vercel 重定向算法优化

Vercel 分享了一篇重定向算法优化的官方博客。

Vercel 采用 JSON 文件、Bloom filter 布隆过滤器,sharding 分片和二分搜索等优化技术持续迭代,实现了低延迟的百万静态重定向。

flow.png

React 文档重写

React 官网中,useActionState()useOptimistic() 这两个 Hooks 的文档重写了。

useActionState() 涵盖了表单集成、错误处理等内容,useOptimistic() 渐进式教学,提供了从简单按钮高到复杂购物车的示例。

这部分文档还更新了互动教程,可以边玩边读。

react-state.png

JS 七年之痒

Patreon 耗时 7 年把百万行 JavaScript 代码迁移到 TypeScript,历经三大时期。

前期,团队授权引入 TS,自愿使用;中期,团队手动迁移,奠定类型良好的基建;后期,团队采用 ts-migrate codemods(代码批改工具),进一步借助 AI 肌肉自动批改,程序员负责复杂架构和最终审核,有组织地迁移。

如果你也有大型项目想要重构,可以借鉴这种渐进式的迁移方案。

focusgroup 新功能公测

无障碍的常见场景之一,是使用 Tab 和方向键在子项中移动聚焦。现存技术的痛点在于,这需要开发者编写 JS 手动管理 tabindex

focusgroup.gif

目前,不同大厂的 UI 库反复造轮子,各自攻克最后焦点记忆、忽略禁用项、动态子项等技术难关。

为此,微软发明了 focusgroup 新特性,联手谷歌和 OpenUI 迭代这个“后 HTML5 时代“的新功能。

focusgroup 目前处于实测阶段,它可以为 tablist / menu 等复合部件添加键盘方向键导航功能,这是一种更符合人体工程学的纯 HTML 声明式魔法,可以消灭 JS “代码屎山“,简化无障碍开发。

<div focusgroup="toolbar" aria-label="Text formatting">
  <button type="button">👍</button>
  <button type="button"></button>
  <button type="button">❤️</button>
</div>

现在,谷歌和微软诚邀大家测评 focusgroup,它们将根据大家的测评反馈继续完善这项新技术。

🚦 版本更新

Lightning CSS v1.32

Lightning CSS 是一个用 Rust 编写的 CSS 解析器 & 压缩神器,被 GitHub 第一 构建工具 Vite 采用,速度比用 JS 编写的同款工具快 100x 倍。

rank.gif

Lighting CSS 发布了 v1.32 次版本:

  • 支持解析器标记 @import 为外部导入,不会被打包
  • 允许访问者添加依赖,实现文件监视或缓存
  • 支持 CSS mix-blend-mode 新属性

Electron v41.0

Electron 是 GitHub 第一 跨平台桌面应用框架,地表最强 IDE VSCode 就是用它写的。

Electron 发布了 v41 主版本,主要包括:

  • 新增了 MacOS 应用新功能,比如支持嵌入 ASAR 完整性摘要,提升安全性;新增 --disable-geolocation flag,禁用定位服务等
  • 技术栈升级,Chromium 升级到 146.0.7680.65,Node 升级到 24.14
  • 支持通过 login 事件,实现 WebSocket 认证

reveal v6.0

reveal 是 GitHub 第一 HTML 幻灯片框架,允许我们使用 Markdown + Web 技术栈来写 PPT。

reveal 发布了 v6.0 主版本,主要包括:

  • 使用 Vite 替代 Gulp 作为构建工具
  • TypeScript 类型开箱即用,无需安装类型开发依赖
  • 新增 @revealjs/react 模块,支持使用 React 组件制作 PPT

Astro v6.0

Astro 是 GitHub 第三 的全栈框架或通用 SSG(静态站点生成器),它允许我们在一个框架中编写所有主流前端框架的组件,包括 React / Vue / Svelte 等。

Astro 发布了 v6.0 主版本,主要包括:

  • 重写 astro dev,借助 Vite Environment API,开发服务器可以跑在非 Node 的生产运行时,包括 Cloudflare Workers,Bun 等,统一开发体验和生产环境
  • 新增 Fonts API,支持从本地文件或谷歌等供应商配置字体
  • 实时内容合集稳定,在请求时获取内容,发布后立即更新,无需重建
  • 内容安全策略 API 稳定
  • 实验性支持基于 Rust 的新编译器,未来有望取代基于 Go 的编译器;顺便一提,React 也爆料将上线基于 Rust 的 React Compiler

Prisma v7.5

Prisma 是 GitHub 第一 NodeJS ORM(对象关系映射),发布了 v7.5 次版本,支持 SQL 数据库的嵌套事务回滚行为,如果外部事务失败,内部嵌套事务也会回滚。

另外,Prisma 官网重做了。

docs.png

Preact v10.29

Preact 是拥抱 DOM 规范的 React 替代品,发布了 v10.29 次版本,实现了 flushSync

💡 前端信息差

Next 升级后遗症

去年,Next 爆出核弹级安全漏洞后,就鼓励用户赶快升级 fix bug 嘛。

结果嘞,部分用户反馈升级到 Next v16 后,请求跟延迟反而增加了。

image.png

由于 Vercel 是按请求收费,请求增加后,成本也会递增,导致经费增长、性能负增长的“消费降级“现象......

因此,部分用户选择回退到了 Next v15,部分用户被退回了 25% 的平台费用。

今年二月底,Next 团队终于找到“万恶之源“,这似乎与预请求有关,并且已经合并了实验性补丁 prefetchInlining flag。

// next.config.js
module.exports = {
  experimental: {
    prefetchInlining: true,
  },
};

如果你升级到 Next v16 后发现成本增长,但性能反而负增长,可以参考这个 GitHub issue,重新制定技术方案。

Fastify 之父分享 Skills

最近 AI 编程中 Skills 的应用挺火的,很多大神纷纷分享自己的 Skills。

Fastify 之父也是 Node 核心贡献者,也分享了自己的 Skills:

  • Node 最佳实践,事件循环和异步错误处理等
  • TypeScript 高级类型系统和 any 消除等
  • ESLint v9 扁平化配置等

最近 Node 开发圈爆发了抵制 AI 提交 Node 源码和开源贡献的反 AI 运动,但目前适当使用 AI 辅助编程还是可行的。

🛠️ 工具推荐

npmx:现代化 npm 注册搜索源

npmx 是一个类似 npm 官网的现代搜索源,旨在优化 npm 官网的设计和开发体验。

npmx 目前处于 Alpha 阶段,我替大家进行了人体试验,目前感觉良好。

首先,npmx 支持更机智的模糊搜索算法,比如搜索“Vite JS“,npm 会搜索出不准确的结果;而 npmx 即便输入的模块名不完全匹配,也能智能搜索出目标模块。

npmx 还支持模块 PK 跑分,帮助开发者挑选模块,比如这是 Vite vs webpack 的“对照实验“。

npmx-pk.png

UI 和无障碍方面,现代化的 npmx 支持深浅模式和自定义主题等。

此外,npmx 支持在线查看模块打包后的源码,不需要我们自己去 GitHub 下载源码打包。

数据可视化方面,我们还可以在线免费下载统计图表,比如本文的这张 Vite vs webpack 的本月下载量。npmx 还支持编辑标注截图,用来发帖或写博客都挺高效的。

npmx-chart.png

npmx 项目由 Nuxt 团队主席提出,团队成员包括 antfu 等前端大神。npmx 采用 VoidZero 和 Vite+ 全家桶构建,提供了更现代化的 UI 设计跟更棒的开发体验,还有更多隐藏功能,欢迎大家测评体验,真的超好用。

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

王自如公开招聘全栈前端,要求有多离谱?

作者 ErpanOmer
2026年3月30日 12:23

上上周在我们前端组的群里,有个年轻小伙甩了张截图进来,并且艾特了我说:老大,你看看现在这招聘要求,我感觉我可以直接提离职去送外卖了😁。

image.png

我点开一看,是最近网上疯传的那张 王自如 招聘 01 号全栈前端员工 的 JD。

image.png

image.png

耐着性子把那长长的一串要求看完,我的第一反应不是这要求有多高,而是一股在职场混了 9 年后,极其熟悉的窒息感扑面而来😖。

不是因为这些技术学不会,也不是因为多难。

而是我发现:自从大模型普及之后,有些老板,是真的越来越不把程序员当人了。😒


这是确定是在招人?

咱们平时招个全栈,懂点 React 加 Node.js,能连个数据库写写 CRUD,这很正常。但你仔细品品这份 JD 里的清单:

常规操作覆盖前端、后端 API、数据库建模。

搞定 Docker,懂 Postgres 的并发、迁移、软删除,还得熟悉 K8S 和 Grafana 监控报警。

懂 iOS / WKWebView 交互,能边做边学把 Swift / SwiftUI 顺手改了。

理解结构化输出、Agent 工具调用,还要“长期订阅” Claude / ChatGPT 等工具(估计还是得员工自己掏钱)。

在技术圈摸爬滚打了 9 年,我面过的候选人没有五百也有三百了🤷‍♂️。

我可以极其直白地说:这根本不叫招聘。这叫一个低成本、全时段、无限并发、最好还能自我迭代系统😒

以前,如果一个老板要求你同时包揽前端、后端、DBA、运维工程师和 iOS 开发的活儿,大家一眼就能看透本质——这公司没钱,想白嫖!!!

但现在,有了 AI 这个遮羞布,这种极其离谱的岗位要求,居然被包装成了一种 前沿、先进、拥抱未来的精英气质。


消失的岗位边界

这几年带团队,我感触最深的一点就是:老板们对 AI 提效的理解,往往和一线工程师存在着巨大的错位。

在有些老板的逻辑里,现在的世界是这样的:

  • 你不会写 Swift?没关系啊,你问 Claude 啊!
  • 你不懂 K8S 部署排障?没关系啊,你把报错贴给 ChatGPT 啊!
  • 你觉得一个人干五个人的活儿干不完?怎么可能,现在不是有 AI 帮你提效 300% 吗?
  • 无力吐槽.....

这套逻辑最恶毒的地方在于,它故意把 工具能加快代码生成速度 和 人可以无限承担责任边界 这两件事混为一谈。

做过真实线上复杂项目的人都知道,写代码从来只占工作的 30%。 真正折磨人的是什么?

是你上午在查 PostgreSQL 的慢查询锁表;中午连饭都没吃完,被叫去对齐 iOS WebView 的桥接通信 Bug;下午切回主干分支改 React 的复杂状态机;到了晚上十一点,K8S 节点 OOM 报警了,你还得爬起来看日志排查内存泄漏😖。

AI 能帮你写正则,能帮你写个 Python 脚本,但 AI 不能替你承担上下文频繁切换带来的大脑宕机,更不能在业务出 P0 级线上事故时替你背锅。

人的精力、耐心、注意力和抗压能力是物理极限。当老板把 有了 AI 作为默认前提时,你做得慢,就是你不够聪明;你扛不住,就是你没有 拥抱变化。一切组织架构上的残缺,最后全变成了你个人的能力不行。

这种精神内耗,才是最阴冷的🤷‍♂️。


压榨最狠的劳动力?

你再去细读那份 JD 里的词:不排斥跨层工作具备快速学习能力边做边补

这些话写得真漂亮。如果在面试里听到,你甚至会觉得这公司扁平、开放、给你试错的空间。

但作为一个老油条,我给你翻译翻译这几句话的潜台词:

不排斥跨层 = 我们连明确的研发流程都没有,哪里缺人你顶哪里?

具备快速学习能力 = 我们没有时间给你培训,也没有资深大佬带你,你自己晚上熬夜看文档把坑填了?

长期订阅 AI 工具作为常态化工作 = 我们希望你主动掏钱、掏业余时间,把自己改造成一个高压生产单元,来证明你配得上这份工资😖。

技术人其实是最不排斥新工具的群体。这几天我们团队自己也在搞 Agent 工作流,大家都玩得很嗨。

但真正让人心寒的,是你的技术热情,正在被当成理所当然的消耗品。你会得越多,老板觉得你理所应当地该干得更多。最后往往是团队里技术最好、最愿意兜底的那个人,被这种 无边界 的期待活活抽干。


如果我全能,凭什么给你打工?

这种招聘风气,正在慢慢改变行业的心理预期。

它会让很多明明已经很优秀、技术底子很扎实的年轻前端产生严重的自我怀疑:我是不是太菜了?别人都能一个人扛一条链路,为什么我还在讲究工程边界?

别怀疑自己。这不是你的问题,是这套标准本身就是反人类的。

一个成熟的研发团队,哪怕只有三五个人,也应该因为工具的进化,而更合理地切分模块、更注重代码的可维护性、更尊重工程师的专注力。而不是借着 AI 的名义,把团队缺口全塞进一个人的身体里。

最后,作为一个敲了 9 年代码、修过无数福报的老兵,我想对抛出这种 JD 的老板们说一句实在话:

如果有一个候选人,他懂现代前端工程体系,能手撕 Node.js 后端,精通数据库并发与事务,能手搭 K8S 运维体系,懂 iOS 桥接,精通 AI Agent 开发链路,而且还具备极强的自我驱动力和产品思维……

这样的人,在 AI 时代,我们称之为 超级个体🤷‍♂️。

但一个真正的超级个体,他的认知和杠杆率是极高的。

当他手握着 AI 赋予的庞大生产力,一个人就能拉起一支满编的数字化军队时,他看到的就不再是一个月薪几万的打工机会,而是更广阔的商业边界与创造自由😒。


所以So...

看到这种 JD,大可不必焦虑,更无需自我怀疑😖。

AI 的浪潮确实在重塑我们的行业,技术栈的融合也是不可逆的趋势。

但我们要时刻清醒地认识到:技术进步的终极目的,是让人工作得更体面、更有创造力,而不是顺手把人的尊严和精力一起优化掉。

守好你的工程底线,把精力花在真正能沉淀护城河的底层逻辑上。

共勉🙌

谢谢大家.gif

CSS 技巧:CSS 中选择 html 元素的各种奇技淫巧

2026年3月30日 12:19

在 CSS 中选中 <html> 元素,这件事看起来再基础不过。大多数情况下,我们只需要写下 html {} 或者 :root {},问题就已经解决了,而且这也是最推荐、最常见的做法。

但如果稍微换个角度去想,除了这些“标准答案”,有没有其他方式也能选中 <html>?答案是——有,而且还不少。

当然,这些写法在实际项目中几乎没有使用价值,甚至可以说有点“多此一举”。不过,它们有一个很有意思的意义——可以帮助我们更深入地理解 CSS 选择器的工作原理。当你开始思考这些问题时,比如 :scope 在什么情况下等价于根元素、& 在非嵌套环境下到底代表谁、:has() 是否可以“反向”匹配父级,甚至能不能选中“没有父元素”的节点,你会发现 CSS 的灵活性远比想象中更高。

所以,这篇内容并不是在教你最佳实践,而更像是一场轻松的探索。我们会用各种“非常规”的方式去选中 <html>,看看 CSS 选择器的边界到底在哪里——以及,它到底能被玩到多离谱。

html:root

刚才提到过,通常我们会使用经典且熟知的 html {}:root{} 来选中 <html> 元素:

html {
    background-color: lightblue;
}

/* 或者 */
:root {
    background-color: lightblue;
}

在大多数情况下,html:root 的效果是一样的,但它们本质上属于两种不同类型的选择器。它们之间的差异主要体现在语义、适用范围和优先级上。

从本质来看,html 是一个元素选择器,它的作用非常直接,就是选中页面中的 <html> 标签本身;而 :root 则是一个伪类选择器,它匹配的是“文档的根元素”。在 HTML 文档中,这个根元素恰好就是 <html> 元素,因此两者在这里表现一致。

不过,这种一致只是“刚好如此”。从语义角度来说,html 表达的是一个具体的标签,而 :root 表达的是一种结构上的位置——也就是最顶层的那个元素。这种差别在其他类型的文档中就会变得明显:

  • HTML 文档::root 匹配 <html>

  • SVG 文档::root 匹配 <svg>

  • RSS 文档::root 匹配 <rss>

  • Atom 文档::root 匹配 <feed>

  • MathML 文档::root 匹配 <math>

  • 其他 XML 文档::root 匹配最外层元素,比如 <note>

:root 的实际意义是什么呢?一个很关键的点在于它的优先级。作为伪类选择器,:root 的权重是 0-1-0,高于元素选择器 html0-0-1。这意味着在样式冲突时,使用 :root 定义的规则更容易生效,从而减少被其他样式覆盖的可能性。

&:scope

接下来,我们来看一些你可能不太熟知的方法。我们可以先从最短、也是最“奇怪”的选择器开始——嵌套选择器 & 。它只有一个字符,但在特定情况下却可以直接选中 <html>

& {
    background-color: lightblue;
}

接下来是 :scope 选择器:

:scope {
    background-color: lightblue;
}

这两个写法之所以都能“指向” <html> ,其实依赖的是它们的回退行为。当 & 没有出现在嵌套规则中时,它不会再“拼接父选择器”,而是退化为指向当前作用域的根;而在没有显示式定义作用域(例如没有使用 @scope)的情况下,:scope 也会表示文档的根节点。于是,在普通的 HTML 文档中,它们最终都会指向 <html>

不过,从设计初衷来看,:scope& 的用途其实完全不同。:scope 用来表示“当前作用域的根元素”,而这个“根”在使用 @scope 时是可以被重新定义的;只有在默认情况下,它才等同于 <html> 。而 & 则主要用于 CSS 嵌套,用来引用当前选择器本身,从而实现更直观的嵌套写法。

例如:

element:hover {
    /* 写法一 */
}

element {
    &:hover {
        /* 等价于上面(注意 &) */
    }
}

如果省略 &,语义就会发生变化:

element {
    :hover {
        /* 实际变成 element :hover(注意空格) */
    }
}

甚至还可以写出更“绕”的形式:

element {
    :hover & {
        /* 表示 :hover element */
    }
}

但一旦 & 脱离了嵌套环境,它就不再参与选择器拼接,而只是简单地指向作用域根。在没有 @scope 的情况下,这个根就是 <html>——这也是它成为一个“隐藏选择器”的原因之一。

温馨提示:如果你对 CSS 的嵌套与作用域机制感兴趣,尤其是 &@scope 的用法,可以进一步阅读《CSS 的嵌套和作用域:&@scope》,会有更深入的理解。

:has(head):has(body)

我们还可以借助 :has() 这个“反向选择器”来选中 <html> 。例如:

:has(head) {
    background-color: lightblue;
}

/* 或者 */
:has(body) {
    background-color: lightblue;
}

之所以可行,是因为在规范上,<html> 元素只应该包含 <head><body> 这两个直接子元素(有点像那种“非黑即白”的设定)。如果你在 <html> 里写入其他标签,那属于无效 HTML,虽然浏览器通常会“帮你收拾残局”,把这些元素自动移动到 <head><body> 中。

更关键的一点是,在标准结构中,没有其他元素可以包含 <head><body> 。因此,当我们写 :has(head):has(body) 时,理论上只会匹配到 <html> 元素本身(除非你刻意写出错误的嵌套结构,但那显然不是正常用法)。

这种方式实用吗?其实并不太实现。但它很好地展示了 :has() 的能力,同时也顺带帮你复习了一下什么才是“合法的 HTML 结构”。

温馨提示:如今,:has() 选择器为 CSS 带来了前所未有的能力,它让我们可以完成许多过去必须依赖 JavaScript 才能实现的效果。如果你对这些更进阶的用法感兴趣,那么下面这几节课的内容非常值得花时间深入了解。

:not(* *)

除了前面那些方法,我们还可以利用一个很有意思的事实: <html> 是页面中唯一没有父元素的节点。基于这一点,可以写出一个略显“花哨”的选择器:

:not(* *) {
    background-color: lightblue;
}

这里的 * * 表示“所有被其他元素包含的元素”,而 :not(* *) 就是把这些元素全部排除掉。最终剩下的,正是那个不被任何元素包含的 <html>。顺便一提,* 被称为“通配选择器”,可以匹配任意元素。

你也可以在中间加入子代组合符 >

:not(* > *) {
    background-color: lightblue;
}

当然,围绕这些思路,我们还可以继续组合出更多“奇技淫巧”的写法,例如:

:is(&) {}
:where(&) {}
&& {}
&&&& {} /* 没错,& 可以无限叠加 */
:has(> body)
:has(> head)
:has(body, head)
/* 等等... */

这些写法有实际价值吗?大多数情况下并没有。但作为一次探索 CSS 选择器能力边界的练习,它们既有趣,也能帮助你更深入地理解选择器背后的机制。

小结

到这里,我们用各种“非常规”的方式,把 <html> 元素从头到尾“折腾”了一遍。从最常见的 html:root,到利用回退行为的 :scope&,再到借助结构关系的 :has(),甚至是通过“排除一切”的 :not(),你会发现:选中 <html> 的方法,远比想象中要多

但更重要的并不是这些写法本身,而是它们背后所体现的规则——选择器的匹配逻辑、作用域的概念、优先级的影响,以及 CSS 在不同上下文中的行为方式。这些才是真正值得理解的部分。

当然,在实际项目中,我们依然应该优先使用简单、清晰、可维护的写法,比如 html:root。那些“奇技淫巧”更多是一种探索和练习,它们的价值在于帮助你建立更扎实的底层认知,而不是直接拿来用在生产环境中。

如果说这篇内容有什么收获,那大概就是:CSS 远不只是“写样式”这么简单,它本身也是一门可以被不断挖掘和玩出花样的语言

[归档][2022-05-16]opensumi看码记录

作者 Kath
2026年3月30日 11:51

贡献点(contribution)

@opensumi/ide-xxx 提供向各个模块提供贡献点的数据结构,如果想为某个模块提供贡献点,要做的是实现这个模块的数据结构,完成自己的逻辑,然后把自己实现的这个 class 注册到 extends BrowserModule 中的class 中。

比如:

所有向编辑器模块贡献功能的贡献点统一使用 BrowserEditorContribution

要写一个 contributions,需要实现以下的方法

interface BrowserEditorContribution {
    /**
     * 用来在合适的时机向 `ResourceService` 注册可以在编辑器内打开的资源。
     *
     * 为了让一个 uri 能够在编辑器中被打开,首先需要向 `ResourceService` 注册一个用于解析 uri 至一个编辑器资源(`IResource`) 的 `IResourceProvider`。
     * 它的主要职责是在这个 uri 在编辑器标签 Tab 上显示时提供它的名称、图标、是否被编辑等状态,以及相应这个 tab 被关闭时的回调等等。
     *
     * @param resourceService
     */
    registerResource?(resourceService: ResourceService): void;
    /**
     * 用来在合适的时机向 `EditorComponentRegistry` 注册编辑器组件、打开方式等功能。
     *
     * 一个 uri 对应的编辑器资源 (`IResource`) 需要能够在编辑器中展示,还需要为它注册对应的一个或者多个打开方式,以及对应打开方式使用的 React 组件。
     * @param editorComponentRegistry
     */
    registerEditorComponent?(editorComponentRegistry: EditorComponentRegistry): void;
    registerEditorDocumentModelContentProvider?(registry: IEditorDocumentModelContentRegistry): void;
    /**
     * @deprecated
     * @param editorActionRegistry
     */
    registerEditorActions?(editorActionRegistry: IEditorActionRegistry): void;
    /**
     * 当进入 IDE 时,编辑器会尝试恢复上一次打开的编辑器组和组内打开的文件
     * 完成后会执行 onDidRestoreState 这个 hook
     */
    onDidRestoreState?(): void;
    registerEditorFeature?(registry: IEditorFeatureRegistry): any;
}

比如说注册可以在编辑器打开的资源,要实现 ResourceService 一个抽象类。

declare abstract class ResourceService {
    /**
     * 注册一个新的 ResourceProvider 会触发该事件
     */
    readonly onRegisterResourceProvider: Event<IResourceProvider>;
    /**
     * 写在一个 ResourceProvider 会触发该事件
     */
    readonly onUnregisterResourceProvider: Event<IResourceProvider>;
    /**
     * 根据uri获得一个资源信息
     * 如果uri没有对应的resource提供者,则会返回null
     * @param uri
     */
    abstract getResource(uri: URI): Promise<IResource | null>;
    /**
     * 注册一个resource提供方
     * @param provider
     */
    abstract registerResourceProvider(provider: IResourceProvider): IDisposable;
    /**
     * 是否能关闭一个资源
     */
    abstract shouldCloseResource(resource: IResource, openedResources: IResource[][]): Promise<boolean>;
    abstract getResourceDecoration(uri: URI): IResourceDecoration;
    abstract getResourceSubname(resource: IResource, groupResources: IResource[]): string | null;
    /**
     * 销毁一个 resource
     * @param resource
     */
    abstract disposeResource(resource: IResource<any>): void;
    /**
     * 是否存在 provider 可以处理某个 uri
     */
    abstract handlesUri(uri: URI): boolean;
}

registerResource(resourceService: ResourceService) {
    // 处理 git 协议的 editor tab 展示信息
    resourceService.registerResourceProvider(xxxProvider);
 }

要注册的资源有一个别名叫 Provider(可以理解提供资源的一方,资源 == Provider)

xxxProvider 就是要注册的资源,provider 实现了 IResourceProvider

interface IResourceProvider {
    scheme?: string;
    /**
     * 一个 provider 是否处理某个资源
     * 返回优先级,这个值越高的 provider 越优先处理, 小于 0 表示不处理
     * 这个比较的计算结果会被缓存,仅仅当 provider 数量变更时才会清空
     * 存在 handlesURI 时, 上面的scheme会被忽略
     */
    handlesUri?(uri: URI): number;
    provideResource(uri: URI): MaybePromise<IResource>;
    provideResourceSubname?(resource: IResource, groupResources: IResource[]): string | null;
    shouldCloseResource?(resource: IResource, openedResources: IResource[][]): MaybePromise<boolean>;
    onDisposeResource?(resource: IResource): void;
}

registerEditorDocumentModelContentProvider 注册 git content provider provider 提供 doc / 文档的内容和 meta 信息

registerEditorDocumentModelContentProvider(registry: IEditorDocumentModelContentRegistry) {
    // 注册 git content provider provider 提供 doc / 文档的内容和 meta 信息
    registry.registerEditorDocumentModelContentProvider(xxxProvider);
 }

此时的 xxxProvider 也是一种资源,注入到 EditorDocumentModelContent 中

xxxProvider 是对 IEditorDocumentModelContentProvider 的实现

interface IEditorDocumentModelContentProvider {
    /**
     * 是否处理这个Scheme的uri
     * 权重等级等同于 handlesUri => 10
     * @param scheme
     */
    handlesScheme?(scheme: string): MaybePromise<boolean>;
    /**
     * 处理一个URI的权重, -1表示不处理, 如果存在handlesUri, handlesScheme将被忽略
     * @param scheme
     */
    handlesUri?(uri: URI): MaybePromise<number>;
    /**
     * 提供文档内容
     * @param uri
     * @param encoding 以某种编码获取内容
     */
    provideEditorDocumentModelContent(uri: URI, encoding?: string): MaybePromise<string>;
    /**
     * 这个文档是否只读(注意只读和无法保存的区别)
     * @param uri
     */
    isReadonly(uri: URI): MaybePromise<boolean>;
    /**
     * 保存一个文档, 如果不存在这个方法,那这个文档无法被保存
     * 当文档无法保存时, 文档永远不会进入dirty状态,并且来自provider的
     * @param uri
     * @param content
     * @param baseContent dirty前的内容
     * @param ignoreDiff 无视diff错误, 强行覆盖保存
     */
    saveDocumentModel?(uri: URI, content: string, baseContent: string, changes: IEditorDocumentChange[], encoding?: string, ignoreDiff?: boolean, eol?: EOL): MaybePromise<IEditorDocumentModelSaveResult>;
    /**
     * 为一个uri提供喜好的语言id,返回undefined则交由编辑器自己去判断
     * @param uri
     */
    preferLanguageForUri?(uri: URI): MaybePromise<string | undefined>;
    provideEOL?(uri: URI): MaybePromise<EOL>;
    /**
     * 为一个uri提供encoding信息, 如果不实现,则默认UTF-8
     * @param uri;
     */
    provideEncoding?(uri: URI): MaybePromise<string>;
    /**
     * 提供这个文件当前内容的md5值,如果不实现这个函数,会使用content再执行计算
     * @param uri
     */
    provideEditorDocumentModelContentMd5?(uri: URI, encoding?: string): MaybePromise<string | undefined>;
    /**
     * 文档内容变更事件,交由modelManager决定是否处理
     */
    onDidChangeContent: Event<URI>;
    onDidDisposeModel?(uri: URI): void;
    /**
     * 是否永远显示 dirty
     * 有些类型的文档(untitled)可能刚创建就是 dirty,允许它以空文件的状态保存
     */
    isAlwaysDirty?(uri: URI): MaybePromise<boolean>;
    /**
     * 是否关闭自动保存功能
     */
    closeAutoSave?(uri: URI): MaybePromise<boolean>;
    /**
     * 猜测编码
     */
    guessEncoding?(uri: URI): Promise<string | undefined>;
}

用一个抽象类来实现 IEditorDocumentModelContentProvider,抽象类中有一个抽象方法。

如何实现抽象方法?

@Injectable()
export class IndexModule extends BrowserModule {
  providers: Provider[] = [
    SchemeContribution,
    ChangesTreeContribution,
    CodeReviewContribution,
    DiffFoldingContribution,
    {
      token: AbstractSCMDocContentProvider, // 以抽象类为token
      useClass: AoneGitDocContentProvider, // 把具体的实现放在 useClass 中,后续xxxProvider 的方法调用来自于 useClass 中的类方法(好特么绕)
    }
  ];
}

题外话: 代码通过 useClass 的方式把 Provider 的具体行为传入。行为中又灌入了 api Injectable,这样 GitDocContentProvider 可以使用 api.XXXX 来进行接口调用,到 api 这层,就是业务方自定义了(真特么的绕啊...)

题外话2:registerToolbarItems 中,注册 toolbar 的 item,registerItem 中参数的数据结构如下。

字段 when 表示的是是否在视图层展示。

interface ICoreMenuItem {
    order?: number;
    /**
     * 决定是否在视图层展示
     */
    when?: string | ContextKeyExpr; // not true/false !!!
    /**
     * 单独变更此 menu action 的 args
     */
    argsTransformer?: ((...args: any[]) => any[]);
}


// usage

{
  id: ChangesTreeViewId,
  when: '!sourceMode' // 通过表达式得到 true/false 的结果。有点像 sql 语法
}

再比如:文件变更树。需要用到组件/命令/toolbar,则需要implements ComponentContribution, CommandContribution, TabBarToolbarContribution

// usage

@Domain(ComponentContribution, CommandContribution, TabBarToolbarContribution)
export class ChangesTreeContribution implements ComponentContribution, CommandContribution, TabBarToolbarContribution {
  // MR Explorer 只注册容器
  registerComponent(registry: ComponentRegistry) {
    registry.register(
      'mr-explorer',
      [
        {
          id: ChangesTreeViewId,
          name: localize('changes.tree', '变更信息'),
          weight: 5,
          priority: 8,                // 按权重 决定排列的顺序
          collapsed: false,
          component: ChangesTreeView, // **注册自己写的组件**
          when: '!sourceMode'
        }
      ],
      {
        title: localize('mr.explorer', '代码变更'),
        iconClass: getIcon('PR'),
        priority: 9,
        containerId: 'mr-explorer',
      },
    );
  }
}

// 如果要注册命令,则需要 implements CommandContribution
@Domain(CommandContribution)
export class xxCommandContribution implements CommandContribution {
  registryCommands(registry: CommandRegistry): void {
    commands.registerCommand(command, {      //command 为复合结构, { id: 唯一标识,...others 非必需}
      execute: () => {
        // do yourself code...
      },
    });
  }
}

// 同理,如果需要对某个tab的 toolbar 添加小部件,需要 implements TabBarToolbarContribution
@Domain(TabBarToolbarContribution)
export class xxCommandContribution implements TabBarToolbarContribution {
  registerToolbarItems(registry: ToolbarRegistry) {
    registry.registerItem({
      id: ChangesTreeCommands.ToggleTree.id, // 如果想使用命令达到同样的效果,需要配置已注册的 command
      command: ChangesTreeCommands.ToggleTree.id, //应该是用 registryCommand 中的command 命令,这样写不是跟id一样??
      viewId: ChangesTreeViewId, // 要注册到哪个 tab 上去,需要标志 tab 的 id
      label: localize('codereview.tree.showTreeModel', '树形模式'),
      toggledWhen: 'changes-tree-mode', //全局注册的上下文 key globalContextKeyService
      order: 4, // toolbar 的顺序
    });
}

看到这儿相信你一定会有这种感觉:opensumi 只是一个框架,它只负责对外提供接口和生命周期,具体要实现一个编辑器,需要自己来完成,像 vue 框架一样,语法(数据结构)已经给定,至于是杀猪还是煮菜端看业务方如何使用。(Yep,我又在说废话了,手动狗头:)

service 一般为 Injectable@Autowired

链路真的太长太长了,看着看着不知道看到哪里了

业务逻辑

文件变更树:ChangesTreeContribution

class ChangesTreeContribution implements ComponentContribution, CommandContribution, TabBarToolbarContribution

changesTreeModelService 中结构出来 decorationService / labelService / commandService,注入到 ChangeTreeNode 组件中

changeTree 本质上是用 FilterableRecycleTree(RecycleTreeFilterDecorator(RecycleTree)) 组件实现

编辑器主干区域:CodeReviewContribution

class CodeReviewContribution extends WithEventBus implements CommandContribution, ClientAppContribution, CommentsContribution, NextMenuContribution, ComponentContribution, BrowserEditorContribution, KeybindingContribution, SlotRendererContribution

依赖 8 个 contribution

我写了一个 AI 代码质量流水线,一行命令搞定 Review + 修复 + 测试 + 报告

作者 DanCheOo
2026年3月30日 11:43

我写了一个 AI 代码质量流水线,一行命令搞定 Review + 修复 + 测试 + 报告

零依赖、支持 6 大 AI 厂商、npx 即跑,让 Vibe Coding 不再裸奔。

背景:Vibe Coding 时代的质量焦虑

2025-2026 年,AI 辅助编码(Cursor、Copilot、Windsurf……)让开发效率翻了几倍,但也带来了一个新问题:

AI 写得快,但谁来保证质量?

你让 Cursor 帮你写了一个 Vue 组件,它跑起来了,但——

  • 有没有 XSS 风险?
  • 边界值处理了吗?空值呢?
  • 类型是不是全用的 any
  • 错误处理有没有吞掉异常?

人工 Review?一个人的项目、外包团队、或者 996 的你,根本没时间。

所以我做了这个工具:ai-review-pipeline —— 让 AI 审查 AI 写的代码。

npx ai-review-pipeline

一行命令,Review → 测试用例 → HTML 报告,全自动。


它能做什么

默认模式:Review + 测试 + 报告(只读)

npx ai-rp --file src/views/Home.vue --full

执行流程:

① AI Code Review(评分 + 问题列表 + 修复建议)
       ↓
② AI 测试用例生成(功能 / 对抗 / 边界三类)
       ↓
③ HTML 可视化报告
       ↓
④ 有 🔴 问题 → exit(1) 阻断 CI;全绿 → exit(0) 放行

不改你一行代码,只审查、只报告。

终端实际运行效果:

Attached_image.png

--fix 模式:自动修复循环

npx ai-rp --fix --file src/views/Home.vue --full

执行流程:

① Review → 发现 3 个 🔴 问题
       ↓
② AI 自动修复(只改质量问题,不碰业务逻辑)
       ↓
③ 再次 Review → 还剩 1 个 🔴
       ↓
④ 再修 → 再审 → 0 个 🔴 ✅
       ↓
⑤ 测试用例生成
       ↓
⑥ HTML 报告
       ↓
⑦ 自动 git commit

修到没问题为止,最多跑 N 轮(默认 5 轮)。如果 N 轮还没修完?也不会卡死——照样出测试和报告,然后 exit(1) 告诉 CI "还有问题"。

自动生成的 HTML 可视化报告:

Attached_image2.png


30 秒上手

# 1. 配 Key(任选一个 AI 服务)
echo 'DEEPSEEK_API_KEY=sk-xxx' >> .env.local

# 2. 跑
npx ai-review-pipeline --file src/ --full

没了。不需要 npm install,不需要配置文件,不需要注册账号。


核心设计决策

为什么是统一流水线而不是分散命令?

v2 的时候我拆了三个命令:reviewfixtest。用了一段时间发现一个问题:

90% 的场景是 "帮我看一遍 + 出测试 + 出报告",每次要敲三遍命令太蠢了。

v3 改成了统一流水线:

操作 命令
默认(review + test + report) ai-rp
自动修复 ai-rp --fix
独立测试生成 ai-rp test --file xxx

一个命令解决 90% 的需求,--fix 是增强开关。reviewfix 保留为别名,向下兼容。

Exit Code 的设计

这个工具的核心场景是 Git Hook + CI 门禁,exit code 必须精确:

场景 Exit Code 含义
Review 通过 0 放行
有 🔴 问题 1 阻断提交/合并
--fix 修好了 0 放行 + auto commit
--fix 没修好 1 阻断,但报告照出

最后一个是关键:即使 --fix 失败,测试和报告也会生成。因为报告是给人看的,不是放行的理由。CI 里可以把报告作为 artifact 上传,方便排查。


6 大 AI 厂商,自动识别

# 设一个 Key 就行,工具自动识别你用的哪家
OPENAI_API_KEY=sk-xxx              # → OpenAI
DEEPSEEK_API_KEY=sk-xxx            # → DeepSeek(国内推荐)
ANTHROPIC_API_KEY=sk-ant-xxx       # → Claude
DASHSCOPE_API_KEY=sk-xxx           # → 通义千问
GEMINI_API_KEY=xxx                 # → Google Gemini
AI_REVIEW_PROVIDER=ollama          # → 本地 Ollama(免费)
Provider 默认模型 特点
OpenAI gpt-4o-mini 稳定,生态好
DeepSeek deepseek-chat 便宜,国内快,推荐
Claude claude-sonnet-4 代码理解力强
通义千问 qwen-plus 阿里云,国内合规
Gemini gemini-2.0-flash Google,免费额度多
Ollama qwen2.5-coder 完全本地,零成本,隐私安全

不想把代码传到云端?用 Ollama:

ollama pull qwen2.5-coder
echo 'AI_REVIEW_PROVIDER=ollama' >> .env.local
npx ai-rp --file src/ --full

审查维度

不是随便跑一遍就完事。Review prompt 按严重程度分了三级:

🔴 必修(阻塞合并)

逻辑错误、安全漏洞(XSS / 注入 / 敏感信息泄露)、数据风险(并发 / 金额精度 / 状态流转错误)

🟡 建议(应该修复)

边界未处理(空值 / undefined / 超时 / 重复提交)、类型问题(any / as 断言)、错误处理缺失

🟢 优化(后续改进)

代码重复、命名不清、性能隐患

最终输出结构化 JSON + Markdown,机器和人都能消费。


测试用例生成

不只是 review,还会自动生成三类测试用例:

类型 覆盖什么
✅ 功能用例 CRUD、状态流转、组件渲染、API 调用
⚔️ 对抗用例 XSS 注入、SQL 注入、超长字符串、越权
🔲 边界用例 空值、0、负数、MAX_SAFE_INTEGER、超时

输出包含用例描述 + 可运行的测试代码(自动检测技术栈:Vitest / Jest / pytest / Go testing)。


项目配置文件

npx ai-rp init

生成 .ai-pipeline.json,提交到 git,团队共享:

{
  "review": {
    "threshold": 95,
    "maxRounds": 5,
    "customRules": [
      "禁止使用 any 类型",
      "API Key / Secret 不得硬编码",
      "所有 API 请求必须有错误处理"
    ]
  },
  "test": {
    "stack": "auto",
    "maxCases": 8
  },
  "report": {
    "outputDir": ".ai-reports",
    "open": true
  }
}

customRules 是亮点——你可以写团队自己的规范,AI 会在每次 review 时强制检查。


CI/CD 集成

GitHub Actions

- name: AI Code Review
  run: npx ai-review-pipeline --json
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Git Hook(lefthook)

# lefthook.yml
pre-push:
  commands:
    ai-review:
      run: npx ai-rp --fix --max-rounds 3

push 之前自动审查 + 修复,没过就阻断。


和同类工具的对比

维度 ai-review-pipeline CodeRabbit SonarQube
部署 npx 即跑,零配置 SaaS,需注册 需部署服务器
依赖 零 dependencies Java 运行时
自动修复 ✅ 多轮迭代
测试生成 ✅ 三类用例
AI 模型 6 家可选 + 本地 固定 规则引擎(非 AI)
数据隐私 Ollama 完全本地 代码上传云端 自建可控
价格 按 token 付费(DeepSeek 几毛钱) $15/月起 社区版免费

核心差异:这不是一个 SaaS 产品,是一个 CLI 工具。你的代码不经过任何中间服务器,直接调 AI API。


技术实现

架构

bin/cli.mjs              # CLI 入口,命令路由
src/commands/
  pipeline.mjs           # 统一流水线(review + fix + test + report)
  review.mjs             # review prompt 构建 + 结果解析
  test.mjs               # 独立测试生成
  init.mjs               # 配置初始化
src/core/
  ai-client.mjs          # 多 Provider 统一调用层
  config.mjs             # 配置加载与合并
  diff.mjs               # git diff / 文件读取
  env.mjs                # .env.local 加载
  report.mjs             # HTML 报告生成
  logger.mjs             # 日志 + i18n
src/i18n/
  zh.mjs / en.mjs        # 中英文消息

几个实现细节

1. 多 Provider 统一调用

除了 Claude(Anthropic 用自己的 Messages API),其他厂商都兼容 OpenAI 格式。所以只需两个调用函数:

// OpenAI / DeepSeek / Qwen / Gemini / Ollama → 统一格式
callOpenAICompatible({ baseUrl, apiKey, model, prompt })

// Claude → 独立处理(不同的 header 和 response 格式)
callClaude({ baseUrl, apiKey, model, prompt })

Provider 自动识别基于 Key 前缀(sk-ant- → Claude)和 Base URL 模式匹配。

2. 安全修复机制

自动修复有一个安全阀:修复后的文件不能低于原文件的 50%。防止 AI "修"出一个删了大半代码的结果。

if (fixed.trim().length < source.trim().length * safetyMinRatio) {
  // 跳过这次修复
}

3. 零依赖

整个工具 0 个 required dependency。Node.js 18+ 的 fetch 直接调 API,child_process 跑 git 命令,fs 读写文件。唯一的 optional peer dependency 是 https-proxy-agent(代理场景)。


真实使用场景

场景一:个人项目的质量兜底

你用 Cursor 一口气写了 2000 行代码,跑一遍:

npx ai-rp --file src/ --full

3 分钟后得到 HTML 报告,告诉你哪里有隐患。

场景二:团队 Git Hook 门禁

lefthook.yml 里配一行,每次 push 前自动审查:

pre-push:
  commands:
    ai-review:
      run: npx ai-rp

有 🔴 问题就阻断,团队代码质量有底线。

场景三:CI/CD 里的质量卡点

GitHub Actions 里加一个 step:

- run: npx ai-review-pipeline --json
  env:
    DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}

PR 不过 review 就不能合并。

场景四:接手遗留代码

接了一个没文档的老项目,先扫一遍:

npx ai-rp --file src/ --full --no-test

快速了解有哪些坑。


常用命令速查

# 默认模式:审查 + 测试 + 报告(不改代码)
ai-rp
ai-rp --file src/views/Home.vue --full
ai-rp --file src/ --full

# 修复模式:审查 + 自动修复 + 测试 + 报告
ai-rp --fix
ai-rp --fix --file src/a.vue --full --max-rounds 3

# 独立测试
ai-rp test --file src/utils.ts

# 初始化配置
ai-rp init

开源地址

npx ai-review-pipeline --file src/ --full

试试看,3 分钟给你的代码做一次体检。


如果对你有帮助,给个 ⭐ 或者掘金点个赞,是我继续迭代的动力。

有问题或建议欢迎提 issue 或评论区交流。

【节点】[Absolute节点]原理解析与实际应用

作者 SmalBox
2026年3月30日 11:43

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP Shader Graph 中,Absolute 节点是一个基础但功能强大的数学运算节点,用于处理各种图形渲染中的数值计算需求。该节点能够将输入的负值转换为正值,同时保持正值不变,这种特性在着色器编程中有着广泛的应用场景。无论是处理颜色值、坐标变换还是光照计算,Absolute 节点都能提供精确的数学支持。

Absolute 节点的核心功能基于数学中的绝对值概念,在计算机图形学中,这种运算特别适用于需要确保数值非负的场景。与传统的编程语言中的 abs()函数类似,Shader Graph 中的 Absolute 节点为着色器开发人员提供了直观的可视化操作方式,无需编写复杂的代码即可实现相同的功能。

在实时渲染中,性能优化是至关重要的考虑因素。Absolute 节点经过高度优化,能够在 GPU 上高效执行,确保不会对渲染性能造成显著影响。这使得开发人员可以放心地在复杂的着色器网络中使用该节点,而不必担心性能开销问题。

描述

Absolute 节点的主要功能是计算输入值的绝对值。从数学角度理解,绝对值表示一个数在数轴上对应点到原点的距离,因此它总是非负的。在 Shader Graph 的上下文中,这个节点可以处理各种类型的输入数据,包括标量值、向量和矩阵,并返回相应的绝对值结果。

该节点的工作原理相对直接:当接收到输入值时,它会检查每个分量的符号。如果分量为正数或零,则保持原值不变;如果分量为负数,则将其转换为相应的正数值。这个过程对输入矢量的每个分量独立进行,确保了处理结果的准确性。

在图形渲染中,Absolute 节点的应用十分广泛。例如,在创建对称图案时,可以使用 Absolute 节点确保图案在正负区域内呈现相同的视觉效果。在光照计算中,它可用于确保某些计算值不会因为方向问题而出现负值,从而避免渲染错误。此外,在处理纹理坐标或顶点位置时,Absolute 节点可以帮助实现各种特殊的视觉效果。

值得注意的是,Absolute 节点支持动态矢量输入,这意味着它可以处理不同维度的数据。无论是简单的浮点数、二维向量、三维向量还是四维向量,该节点都能正确计算其绝对值。这种灵活性使得 Absolute 节点可以适应各种复杂的着色器需求。

从性能角度来看,Absolute 节点对应的 GPU 指令通常非常高效。现代图形处理器对绝对值运算有专门的硬件支持,这意味着使用 Absolute 节点通常不会对渲染性能产生明显影响。然而,在性能关键的场景中,开发人员仍应注意避免不必要的绝对值计算,特别是在循环或频繁调用的着色器部分。

端口

Absolute 节点的端口设计简洁明了,遵循了 Shader Graph 节点的一般设计原则。了解每个端口的功能和特性对于正确使用该节点至关重要。

输入端口

输入端口标记为"In",是 Absolute 节点接收数据的入口。这个端口具有以下重要特性:

  • 数据类型支持:输入端口支持动态矢量类型,这意味着它可以接受各种维度的数据输入。具体来说,它可以处理 float、float2、float3 和 float4 类型的值。这种动态类型支持使得节点非常灵活,可以适应不同的使用场景。
  • 连接兼容性:输入端口可以与 Shader Graph 中任何输出相同或兼容数据类型的节点相连。这包括常数节点、属性节点、数学运算节点以及其他各种计算节点的输出。当连接不同维度的数据时,Shader Graph 会自动进行适当的类型转换。
  • 数值范围:输入端口对接收的数值范围没有限制,可以处理任意大小的正数、负数或零。对于特殊值如无穷大或 NaN(非数字),节点的行为取决于底层 GPU 的实现,但通常会遵循 IEEE 浮点数标准。
  • 实时更新:输入端口的值会随着着色器的每次执行而更新,这意味着它可以用于处理动态变化的数值,如随时间变化的动画参数或基于顶点位置的计算结果。

输出端口

输出端口标记为"Out",是 Absolute 节点计算结果的出口。这个端口具有以下关键特性:

  • 数据类型一致性:输出端口的数据类型始终与输入端口的数据类型保持一致。如果输入是 float3 类型,那么输出也将是 float3 类型,确保与后续节点的兼容性。
  • 数值特性:输出端口的值始终为非负值。对于输入中的每个分量,输出都会返回其绝对值。具体来说,正数和零保持不变,负数会被转换为相应的正数。
  • 连接灵活性:输出端口可以连接到任何接受相同数据类型的输入端口。这使得 Absolute 节点可以轻松集成到复杂的着色器网络中,与其他数学节点、纹理采样节点或最终输出节点相连。
  • 精度保持:输出端口会尽可能保持输入的数值精度。在绝大多数情况下,绝对值运算不会引入额外的精度误差,这对于需要高精度计算的图形效果非常重要。

理解这些端口的特性和行为对于有效使用 Absolute 节点至关重要。在实际应用中,开发人员应当注意输入数据的类型和范围,确保它们符合预期的计算需求。同时,了解输出数据的特性有助于正确解释和使用计算结果,避免在复杂的着色器网络中引入错误。

生成的代码示例

Absolute 节点在 Shader Graph 中生成的底层代码反映了其核心功能。通过分析这些代码示例,我们可以更深入地理解节点的实现原理和行为特性。这对于高级着色器开发和在特定情况下优化性能都非常有帮助。

基本代码结构

Absolute 节点对应的 HLSL 代码通常采用函数形式实现。最基本的单精度浮点数版本如下所示:

HLSL

void Unity_Absolute_float(float In, out float Out)
{
    Out = abs(In);
}

这个简单的函数接受一个浮点数输入参数 In,并通过 HLSL 内置的 abs()函数计算其绝对值,然后将结果存储在输出参数 Out 中。这种实现方式直接且高效,利用了 GPU 对基本数学运算的硬件支持。

向量处理扩展

当处理多维数据时,Absolute 节点的实现会相应扩展以支持各种向量类型:

HLSL

// 二维向量版本
void Unity_Absolute_float2(float2 In, out float2 Out)
{
    Out = abs(In);
}

// 三维向量版本
void Unity_Absolute_float3(float3 In, out float3 Out)
{
    Out = abs(In);
}

// 四维向量版本
void Unity_Absolute_float4(float4 In, out float4 Out)
{
    Out = abs(In);
}

这些向量版本的函数表明,Absolute 节点对输入向量的每个分量独立计算绝对值。这种分量级别的操作是并行处理的,充分利用了 GPU 的并行计算能力,确保了高效执行。

精度变体实现

在实际的着色器应用中,不同的场景可能需要不同的数值精度。Absolute 节点通常会提供多种精度版本的实现:

HLSL

// 半精度版本(适用于移动平台或性能敏感场景)
void Unity_Absolute_half(half In, out half Out)
{
    Out = abs(In);
}

void Unity_Absolute_half2(half2 In, out half2 Out)
{
    Out = abs(In);
}

// 单精度版本(标准精度,适用于大多数场景)
void Unity_Absolute_float(float In, out float Out)
{
    Out = abs(In);
}

void Unity_Absolute_float2(float2 In, out float2 Out)
{
    Out = abs(In);
}

这些不同精度的实现允许开发人员根据目标平台和性能要求选择合适的计算精度。在移动平台上使用半精度计算可以显著提高性能,同时保持足够的视觉质量。

特殊平台考虑

在不同图形 API 和硬件平台上,绝对值函数的实现可能略有差异。Shader Graph 通常会处理这些平台差异,确保一致的行为:

HLSL

// 针对特定平台的优化实现
#ifdef UNITY_GLES
    // 针对OpenGL ES平台的特定实现
    #define UNITY_ABS(x) ((x) < 0 ? -(x) : (x))
#else
    // 标准桌面平台的实现
    #define UNITY_ABS(x) abs(x)
#endif

void Unity_Absolute_float(float In, out float Out)
{
    Out = UNITY_ABS(In);
}

这种条件编译确保了 Absolute 节点在不同平台上的兼容性和最优性能。对于着色器开发人员来说,这种底层细节是透明的,他们可以专注于视觉效果的设计而不必担心平台兼容性问题。

实际应用中的代码集成

当在 Shader Graph 中使用 Absolute 节点时,生成的代码会被整合到主着色器函数中。以下是一个简单的示例,展示了 Absolute 节点如何在实际着色器中应用:

HLSL

// 由Shader Graph生成的顶点着色器部分
v2f vert (appdata v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);

    // 应用Absolute节点计算
    float3 absolutePosition;
    Unity_Absolute_float3(v.vertex.xyz, absolutePosition);

    // 其他顶点变换计算...
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);

    return o;
}

// 由Shader Graph生成的片段着色器部分
fixed4 frag (v2f i) : SV_Target
{
    // 应用Absolute节点处理UV坐标
    float2 absoluteUV;
    Unity_Absolute_float2(i.uv, absoluteUV);

    // 采样纹理
    fixed4 col = tex2D(_MainTex, absoluteUV);

    // 应用颜色
    col *= _Color;

    return col;
}

这个示例展示了 Absolute 节点在顶点着色器和片段着色器中的典型应用。在顶点着色器中,它可能用于处理顶点位置;在片段着色器中,它可能用于处理纹理坐标或其他计算参数。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Js也能写外挂?8 行代码改掉《植物大战僵尸》的阳光值!对于js来说超越调用大漠超越调用memory.js

2026年3月30日 10:57

Node.js 内存修改入门:植物大战僵尸阳光值修改实战

ScreenShot_2026-03-30_100609_991.png

Node.js 内存修改 游戏安全 植物大战僵尸

目录

  1. 前言
  2. 环境准备
  3. 核心原理
  4. 代码实现
  5. 进阶:锁定阳光
  6. 总结

前言

原生node模块 ---- 获取地址

www.ylcp.online/engine/

对于 node 来说性能超越 大漠 性能超越 memoryjs

本文将以经典游戏《植物大战僵尸》为例,演示如何使用 Node.js 通过内存读写的方式修改游戏中的阳光值。整个实现过程仅使用 8 行代码,非常适合作为内存修改的入门教程。

声明 本文仅供学习和技术研究使用,请勿用于破坏游戏平衡或商业用途。游戏修改可能违反游戏服务条款,请在合法合规的前提下学习。

环境准备

  • Node.js v18 或更高版本(推荐 v24.x)
  • 游戏:植物大战僵尸 PC 版
  • 模块:用于进程内存读写的 Node.js 原生扩展 目前自己写的c++高性能 .node 模块地址在上面 👆

核心原理

修改游戏内存的核心步骤如下:

  1. 找到目标进程 — 通过窗口标题定位游戏进程 PID
  2. 获取模块基址 — 找到 PlantsVsZombies.exe 的内存基地址
  3. 计算偏移链 — 通过多级指针偏移找到阳光值的真实地址
  4. 写入数值 — 向目标地址写入新的阳光值

阳光值偏移链: 基址 PlantsVsZombies.exe + 0x2A9EC0 → 偏移 0x768 → 偏移 0x5560 → 最终地址

代码实现

ScreenShot_2026-03-30_094346_830.png

ScreenShot_2026-03-30_094550_574.png

ScreenShot_2026-03-30_094605_310.png

完整代码

const engine = require('./engine.node');
const win = engine.getAllWindows().find(w => w.title.includes("植物大战僵尸"));
const baseAddr = Number(engine.findModuleByName(win.processId, "PlantsVsZombies.exe").baseAddress);
const sunAddr = engine.followPointerChain(win.processId, {
    baseAddress: (baseAddr + 0x2A9EC0).toString(16),
    offsets: [0x768, 0x5560],
}).finalAddress;
engine.writeInt32(win.processId, sunAddr, 9999);
console.log(`当前阳光: ${engine.readInt32(win.processId, sunAddr)}`);

代码拆分 👇

1. 获取进程与模块基址

首先加载原生模块,根据窗口标题找到游戏进程,并获取 PlantsVsZombies.exe 的基址:

const engine = require('./engine.node');
const win = engine.getAllWindows().find(w => w.title.includes("植物大战僵尸"));
const baseAddr = Number(engine.findModuleByName(win.processId, "PlantsVsZombies.exe").baseAddress);
2. 沿指针链查找阳光地址

通过多级指针追踪,最终定位到阳光值所在的内存地址:

const sunAddr = engine.followPointerChain(win.processId, {
    baseAddress: (baseAddr + 0x2A9EC0).toString(16),
    offsets: [0x768, 0x5560],
}).finalAddress;
3. 写入新的阳光值

向计算出的地址写入 9999

engine.writeInt32(win.processId, sunAddr, 9999);
console.log(`当前阳光: ${engine.readInt32(win.processId, sunAddr)}`);

运行后控制台输出:

当前阳光: 9999

进阶:锁定阳光

如果希望阳光值持续保持在 9999(游戏会不断减少阳光),可以使用内存冻结功能:

// 锁定阳光(每 100ms 写入一次)
engine.freezeMemoryValue(win.processId, {
    address: sunAddr,
    valueType: "int32",
    value: [0x0F, 0x27, 0x00, 0x00],  // 9999 小端序
    intervalMs: 100,
});
console.log("阳光已锁定为 9999,按 Ctrl+C 停止");

核心参数说明:

参数 说明
address sunAddr 阳光值的内存地址
valueType "int32" 32位有符号整数
value [0x0F, 0x27, 0x00, 0x00] 9999 的小端序字节表示
intervalMs 100 每 100ms 刷新一次

总结

通过本文,我们了解了使用 Node.js 进行游戏内存修改的基本流程:

步骤 API 作用
1. 找窗口 getAllWindows() 通过标题匹配获取游戏窗口
2. 找基址 findModuleByName() 获取 EXE 模块的内存基址
3. 追指针 followPointerChain() 多级指针链追踪到目标地址
4. 写内存 writeInt32() 写入指定值到目标地址
5. 锁定值 freezeMemoryValue() 定时刷新,持续保持目标值

本文仅展示了内存修改的基本原理。在实际学习中,你可以尝试修改更多的游戏属性(如金币、生命值、冷却时间等),进一步理解 Windows 进程内存管理机制。

我们是怎么用 TanStack 全家桶的

作者 Mahut
2026年3月30日 10:24

这篇文章讲的是 TanStack Query、TanStack Store、TanStack Router 三个库的实际使用思路,结合真实业务场景说明"为什么这么用",以及跟其他同类方案比有什么不同。


先说说这三个包是干什么的

"@tanstack/react-query": "^5.x",
"@tanstack/react-store": "^0.x",
"@tanstack/react-router": "^1.x"

分工非常清晰:

  • react-query:管"服务器状态",也就是从接口拿来的数据
  • react-store:管"客户端状态",纯前端的 UI 状态
  • react-router:管路由,基于文件系统自动生成

一、TanStack Query:接口数据管理

从"手写请求"说起

大多数人第一次写数据请求,差不多是这样:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <Spin />;
  if (error) return <div>出错了</div>;
  return <div>{user?.name}</div>;
}

写一次还好,写多了就会发现几个问题一直在重复出现:

  1. 同一个接口在两个组件里各请求了一次,明明数据一样,却发了两次请求
  2. 切换路由回来,数据已经过期,但没有任何机制去刷新
  3. loading / error / data 这套模板,每个接口都得写一遍
  4. 组件卸载后 setState 报错,要手动加 cleanup

这些其实都是"服务器状态管理"的问题,不是 React 本身的问题。

换成 TanStack Query

function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Spin />;
  if (error) return <div>出错了</div>;
  return <div>{user?.name}</div>;
}

代码少了,但做的事情更多了:

  • 相同 queryKey 的请求全局只发一次,多个组件共享同一份缓存
  • 窗口重新聚焦时自动后台刷新(可配置)
  • 组件卸载时自动取消,不会有 setState 警告
  • 内置 loading / error / data 状态,不用手动维护

和 SWR 比有什么不同

SWR 是 Vercel 出的,功能和 TanStack Query 有很多重叠。主要区别:

对比项 TanStack Query SWR
Mutation 支持 useMutation,完善 需要手动实现或用第三方
开发者工具 官方 Devtools,可视化缓存状态 无官方 Devtools
缓存控制粒度 极细,可以按 key 精准失效 相对粗粒度
重试策略 自定义函数,按错误类型决定 配置项较少
包体积 较大 更小
使用场景 复杂应用,需要精细控制 简单场景,快速上手

如果项目比较简单、主要是 GET 请求展示数据,SWR 完全够用。但我们的项目涉及大量 mutation、复杂的缓存失效逻辑、自定义错误处理,TanStack Query 的控制粒度更合适。

Query Key:数据的"身份证"

queryKey 是 TanStack Query 的核心概念。它相当于每份缓存数据的身份证,同一个 key 对应同一份数据。

// 静态 key:数据和参数无关
useQuery({ queryKey: ['currentUser'], queryFn: fetchCurrentUser });

// 动态 key:参数不同,缓存独立存储
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
useQuery({ queryKey: ['posts', { page, status }], queryFn: () => fetchPosts(page, status) });

key 设计得好,缓存就清晰。比如删除一篇文章后,让 ['posts'] 相关的缓存失效,列表会自动刷新:

const deleteMutation = useMutation({
  mutationFn: deletePost,
  onSuccess: () => {
    // 精准失效:以 ['posts'] 开头的所有缓存都失效
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  }
});

用 query-key-factory 统一管理 key

项目大了之后,key 散落在各处很难维护。我们用 @lukemorales/query-key-factory 把所有 key 集中管理:

import { createQueryKeys, mergeQueryKeys } from '@lukemorales/query-key-factory';

const userKeys = createQueryKeys('users', {
  // 无参数的静态 key:对象形式
  current: {
    queryKey: null,
    queryFn: fetchCurrentUser,
  },
  // 有参数的动态 key:函数形式
  detail: (id: string) => ({
    queryKey: [id],
    queryFn: () => fetchUser(id),
  }),
  list: (params: UserListParams) => ({
    queryKey: [params],
    queryFn: () => fetchUsers(params),
  }),
});

const postKeys = createQueryKeys('posts', {
  list: (params: PostListParams) => ({
    queryKey: [params],
    queryFn: () => fetchPosts(params),
  }),
});

// 合并成统一入口
export const queries = mergeQueryKeys(userKeys, postKeys);

在组件里:

// 静态 key,直接传(不加括号)
const { data: currentUser } = useQuery(queries.users.current);

// 动态 key,传参调用(加括号)
const { data: user } = useQuery(queries.users.detail(userId));
const { data: posts } = useQuery(queries.posts.list({ page, status }));

静态和动态 key 的写法差异很关键,一定要区分:

  • 无参数 → 写成对象,用时不加括号
  • 有参数 → 写成函数,用时加括号传参

在 React 之外读缓存

有时候需要在非 React 环境(工具函数、事件回调等)里读接口数据,不能用 hook。把 QueryClient 做成单例,在任何地方都能访问:

// query-client.ts(单例)
export const queryClient = new QueryClient({ ... });

// 工具函数里
import { queryClient } from './query-client';

export function getCurrentUserId() {
  // 直接从缓存读,同步返回,不发请求
  const user = queryClient.getQueryData(queries.users.current.queryKey);
  return user?.id;
}

getQueryDatauseQuery 的区别:

  • useQuery:建立订阅,数据变化时组件重渲染,必要时会自动发请求
  • getQueryData:只读一次当前缓存值,不订阅、不请求、不触发渲染

全局错误处理

不需要每个 useQuery 都写 onError,在创建 QueryClient 时统一处理:

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (error instanceof UnauthorizedError) {
        // 401 → 跳登录页
        router.navigate({ to: '/login' });
        return;
      }
      if (error instanceof BusinessError) {
        // 业务错误 → 弹提示
        notification.error(error.message);
        return;
      }
      // 其他错误 → 通用提示
      notification.error('网络异常,请稍后重试');
    }
  }),
});

如果某个请求不想走全局处理,在 meta 里打个标记就行:

useQuery({
  queryKey: ['some-data'],
  queryFn: fetchSomeData,
  meta: { skipGlobalError: true }, // 这个请求自己处理错误
});

二、TanStack Store:客户端状态管理

和 Redux、Zustand、Jotai 的区别

说到 React 状态管理,常见选项很多,先对比一下:

Redux(含 Redux Toolkit)

优点是生态成熟、Devtools 强大、适合大型团队统一规范。缺点是心智负担重——即便用了 RTK,一个状态需要定义 slice、action、selector,模板代码还是偏多。Redux 的更新是"全局 dispatch 一个 action",不够直接。

Zustand

比 Redux 轻很多,API 极简:

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));

缺点是 selector 的精准订阅需要手动处理,写不好容易有不必要的重渲染。另外它是 hook-based,在 React 之外读取状态需要额外处理。

Jotai

原子化状态,每个 atom 独立管理,组合灵活。适合状态之间关系复杂、需要细粒度订阅的场景。但原子数量多了之后管理起来也有心智成本。

TanStack Store

import { Store, useStore } from '@tanstack/store';

const counterStore = new Store({ count: 0 });

// 组件里:selector 决定订阅哪部分
function Counter() {
  const count = useStore(counterStore, (s) => s.count);
  return <div>{count}</div>;
}

// React 之外:直接读 .state
function getCount() {
  return counterStore.state.count;
}

// 修改
counterStore.setState((s) => ({ ...s, count: s.count + 1 }));

TanStack Store 的设计哲学很简单:Store 是数据容器,useStore + selector 是订阅机制。没有 action、没有 reducer,直接改。

最大的特点是在 React 内外都能用同一个 store:React 组件用 useStore,非 React 环境读 .state,改状态用 setState。这让我们可以用一套数据流贯穿 React 组件和普通 JS 逻辑。

对比项 Redux Toolkit Zustand Jotai TanStack Store
模板代码 中等 极少
精准订阅 需配合 reselect 手动 天然原子化 selector 函数
React 外访问 store.getState() useStore.getState() getDefaultStore() store.state
包体积 较大 极小
适合场景 大型应用 通用 原子化状态 简单~中型

我们选 TanStack Store 的原因是:项目里状态的读写场景横跨 React 组件和各种命令处理逻辑,需要一个在两种场景里用法对称的方案。

queries / observe:读数据的两种模式

我们把读状态的方法按使用场景分成两组:

在 React 组件里(响应式):

// 封装 useStore,组件订阅,状态变就重渲染
const editorQueries = {
  useSelectedIds: () =>
    useStore(editorStore, (s) => s.selectedIds),

  useClipById: (id: string) =>
    useStore(editorStore, (s) => s.clips[id]),

  // selector 里可以做派生计算
  useSortedClips: () =>
    useStore(editorStore, (s) =>
      Object.values(s.clips).sort((a, b) => a.startTime - b.startTime)
    ),
};

// 用法
function ClipItem({ id }) {
  const clip = editorQueries.useClipById(id);
  return <div>{clip.name}</div>;
}

在事件处理器 / 工具函数里(直接读):

const editorObserve = {
  getSelectedIds: () => editorStore.state.selectedIds,
  getClipById: (id: string) => editorStore.state.clips[id],
};

// 用法
function handleKeyDown(e) {
  if (e.key === 'Delete') {
    const ids = editorObserve.getSelectedIds();
    deleteClips(ids);
  }
}

这个分法的好处:命名本身就是文档。useXxx 的前缀告诉你它是 Hook,只能在组件里用;getXxx 是普通函数,哪里都行。

写状态:setState

TanStack Store 更新状态只有一个 API:setState,接收一个函数,入参是当前 state,返回值是新 state。

const todoStore = new Store({
  items: [] as Todo[],
  filter: 'all' as 'all' | 'active' | 'done',
});

// 添加一条
todoStore.setState((s) => ({
  ...s,
  items: [...s.items, { id: Date.now(), text: '买菜', done: false }],
}));

// 修改某一条
todoStore.setState((s) => ({
  ...s,
  items: s.items.map(item =>
    item.id === targetId ? { ...item, done: true } : item
  ),
}));

// 切换过滤条件
todoStore.setState((s) => ({ ...s, filter: 'active' }));

setState 在 React 组件内外都能调,没有任何限制:

// 组件里
function AddTodo() {
  const handleAdd = () => {
    todoStore.setState((s) => ({
      ...s,
      items: [...s.items, { id: Date.now(), text: '新任务', done: false }],
    }));
  };
  return <button onClick={handleAdd}>添加</button>;
}

// 普通函数里,完全一样
function markAllDone() {
  todoStore.setState((s) => ({
    ...s,
    items: s.items.map(item => ({ ...item, done: true })),
  }));
}

封装 setter:让写操作有名字

直接暴露 setState 给组件用没什么问题,但随着状态变复杂,每次内联写更新逻辑会让组件很臃肿。常见的做法是把写操作封装成有名字的函数,集中管理:

// store.ts
export const cartStore = new Store({
  items: [] as CartItem[],
  coupon: null as string | null,
});

// 封装写操作,像 API 一样暴露出去
export const cartActions = {
  addItem(item: CartItem) {
    cartStore.setState((s) => ({
      ...s,
      items: [...s.items, item],
    }));
  },
  removeItem(id: string) {
    cartStore.setState((s) => ({
      ...s,
      items: s.items.filter(i => i.id !== id),
    }));
  },
  applyCoupon(code: string) {
    cartStore.setState((s) => ({ ...s, coupon: code }));
  },
  clear() {
    cartStore.setState((s) => ({ ...s, items: [], coupon: null }));
  },
};

组件里调用就很干净:

function CartItem({ item }) {
  return (
    <div>
      {item.name}
      <button onClick={() => cartActions.removeItem(item.id)}>删除</button>
    </div>
  );
}

这只是一种组织方式,TanStack Store 本身没有强制要求。你也可以直接在组件里调 setState,取决于项目规模和团队习惯。

派生状态:在 selector 里算

不需要额外引入 computed/derived 概念,派生计算直接放在 useStore 的 selector 里:

// 直接在 selector 里过滤、计算
const activeTodos = useStore(todoStore, (s) =>
  s.items.filter(item => !item.done)
);

const stats = useStore(todoStore, (s) => ({
  total: s.items.length,
  done: s.items.filter(i => i.done).length,
  active: s.items.filter(i => !i.done).length,
}));

需要注意的是,返回对象或数组时,每次 selector 执行都会生成新引用,TanStack Store 的浅比较会认为值变了,导致不必要的重渲染。可以用 shallow 比较函数解决:

import { useStore, shallow } from '@tanstack/store';

// 第三个参数传入 shallow,对象/数组用浅比较
const stats = useStore(todoStore, (s) => ({
  total: s.items.length,
  done: s.items.filter(i => i.done).length,
}), shallow);

或者把派生计算拆出来,selector 只返回原始数据,组件里再算:

const items = useStore(todoStore, (s) => s.items);
const activeTodos = useMemo(() => items.filter(i => !i.done), [items]);

三、TanStack Router:文件系统路由

和 React Router 比有什么不同

React Router v6 是目前最流行的路由库,TanStack Router 是后来者,但在类型安全和数据预加载上走得更远。

类型安全

React Router 的 useParamsuseSearchParams 返回的是 string | undefined,你得自己转换类型:

// React Router
const { id } = useParams(); // id: string | undefined
const numericId = Number(id); // 手动转换,没有类型保证

// TanStack Router
const { id } = Route.useParams(); // id: number(根据路由定义自动推断)

TanStack Router 的类型是从路由定义一路推导下来的,paramssearchloaderData 全部有类型,不需要手动断言。

数据预加载

TanStack Router 在路由层面原生支持数据预加载,和 TanStack Query 配合非常自然:

// 路由文件里定义 loader
export const Route = createFileRoute('/users/$id')({
  // 路由匹配时自动执行,数据准备好了再渲染
  loader: ({ context: { queryClient }, params }) =>
    queryClient.ensureQueryData(queries.users.detail(params.id)),

  component: UserDetail,
});

function UserDetail() {
  // loader 保证数据一定在缓存里,不会有 loading 状态
  const { data: user } = useQuery(queries.users.detail(Route.useParams().id));
  return <div>{user.name}</div>;
}

React Router 的 loader 也有类似功能,但和 React Query 配合需要额外处理,类型推断也没有 TanStack Router 顺畅。

文件系统路由

这是 TanStack Router 的另一个亮点。路由不需要手动配置,文件结构就是路由结构:

routes/
  index.tsx          → /
  about.tsx          → /about
  users/
    index.tsx        → /users
    $id.tsx          → /users/:id
    $id.edit.tsx     → /users/:id/edit
  _layout.tsx        → 布局路由(不影响 URL)

构建时插件自动生成路由树,新增路由只需要新建文件,不用去路由配置文件里注册。

对比项 React Router v6 TanStack Router
类型安全 较弱,params 是 string 完全类型推断
数据预加载 有 loader,但与 RQ 整合需配置 原生与 TanStack Query 集成
文件系统路由 无(需手动配置) 插件支持,自动生成
Search Params 手动 parse/stringify 类型安全,支持 schema 校验
生态成熟度 极成熟 较新,快速迭代
上手难度 中等(类型系统较复杂)

React Router 的优势是生态和稳定性,TanStack Router 的优势是类型安全和与 TanStack Query 的深度集成。如果项目新起,且已经用了 TanStack Query,选 TanStack Router 可以获得最顺滑的开发体验。

在 Electron 里用 Hash History

在 Electron 里,页面通过 file:// 协议加载,使用 Browser History 会导致路径被操作系统按文件路径解析,出各种奇怪的问题。改用 Hash History 就没这个问题:

import { createHashHistory, createRouter } from '@tanstack/react-router';

const router = createRouter({
  routeTree,
  history: createHashHistory(),
  context: { queryClient }, // 把 queryClient 注入,loader 里可以用
});

URL 会变成 file:///path/to/app#/users/123 这种形式,# 后面的部分由前端路由处理,不会触发文件系统访问。


四、整体数据流

三个库协作起来,每层职责清晰:

用户操作
  │
  ├─ 接口数据(服务器状态)
  │    └─ useQuery / useMutation
  │         ├─ 结果进 QueryClient 缓存
  │         ├─ 所有订阅同一个 key 的组件自动更新
  │         └─ 错误统一走 QueryCache.onError 处理
  │
  ├─ 客户端状态变更
  │    └─ store.setState() / 封装的 actions
  │         └─ useStore selector 订阅的组件自动重渲染
  │
  └─ 路由切换
       └─ TanStack Router 匹配路由
            └─ loader 预加载数据到 QueryClient 缓存
                 └─ 组件渲染时数据已就绪,无 loading 闪烁

五、一些使用心得

query key 要设计成层级结构

// 好的设计:层级清晰
['users']                    // 所有 user 相关
['users', 'list', params]    // user 列表
['users', 'detail', id]      // 某个 user 详情

// 失效时可以精准控制范围
queryClient.invalidateQueries({ queryKey: ['users'] });          // 失效所有
queryClient.invalidateQueries({ queryKey: ['users', 'list'] });  // 只失效列表

selector 写法影响渲染性能

// 差:每次渲染都返回新数组,浅比较失败,组件永远重渲染
useStore(store, (s) => Object.values(s.items));

// 好:在 selector 外部记住引用,或者用稳定的数据结构
useStore(store, (s) => s.itemIds); // 只订阅 id 数组,变化频率低

staleTime 不是越大越好

// staleTime: 0(默认)→ 每次 mount 都重新请求,数据始终最新,但请求频繁
// staleTime: 3000   → 3 秒内不重新请求,适合变化不频繁的数据
// staleTime: Infinity → 永不过期,适合字典、枚举等几乎不变的数据

根据数据的更新频率设置合适的 staleTime,可以在"数据新鲜度"和"请求次数"之间取得平衡。

ClawBench:智谱、字节、小米共有4款模型跻身全球前十

2026年3月30日 13:45
36氪获悉,评测机构ClawBench发布了最新大型模型榜单。北京人工智能企业智谱、字节、小米共有4款模型跻身全球前十。其中,智谱GLM-5-Turbo以93.9分的成绩登顶榜首;字节Doubao-Seed-2.0-lite位居第二,使用成本为全榜最低;小米MiMo-V2-Omni位列第9,运行速度最快。

全国首个万卡级全栈自主可控智算集群点亮

2026年3月30日 13:33
深圳人工智能高质量发展迎来新突破:3月26日,深圳市投建的11000P 智能算力集群正式点亮,加上去年先期点亮的3000P,目前该集群已全面建成14000P智能算力。这是全国首个使用全国产先进芯片构建的万卡级全栈自主可控智算集群。(深圳特区报)

头部刀具厂商订单饱满,产业链人士预计今年Q2产能利用率与净利率将保持高位

2026年3月30日 13:25
记者从多位产业链人士获悉,数控刀片行业当前供需两旺。今年Q1头部厂商数控刀片产能利用率较去年同期显著提升,不同品类产品订单周期约2-3个月。在量价齐升驱动下,产品净利率同比提升。某头部刀具厂商相关人士向记者表示,前期产品提价对净利率的拉动效应,高于销量放量带来的贡献。依托前期原料及成品库存储备,预计今年Q2产能利用率与净利率仍将保持高位。(财联社)

市场监管总局:推动研究制定《禁止网络不正当竞争条例》

2026年3月30日 13:21
36氪获悉,市场监管总局发布关于进一步贯彻实施《中华人民共和国反不正当竞争法》的通知。其中提到,及时总结监管实践经验,推动研究制定《禁止网络不正当竞争条例》。系统推动制修订规范仿冒混淆、禁止商业贿赂等规章,为行政执法提供规范指引。推动研究发布相关行业市场价格与竞争行为评估报告,引导良性竞争,逐步构建多层次的反不正当竞争政策工具箱。推动健全行政执法、民事救济与刑事司法紧密衔接机制,加强案件线索移送与协同查核,探索形成信息共享、执法联动的工作格局。

市场监管总局:防治大型企业等经营者拖欠中小企业账款

2026年3月30日 13:15
36氪获悉,市场监管总局发布关于进一步贯彻实施《中华人民共和国反不正当竞争法》的通知。其中提到,防治大型企业等经营者拖欠中小企业账款。构建多维度治理大型企业等经营者拖欠中小企业账款的协同机制。要结合交易方在行业中的地位、交易习惯、对中小企业以及整体市场秩序的影响等多方面因素,对大型企业等经营者是否具有优势地位进行综合判断。准确认定违法行为,主体应当为大型企业等经营者,行为上必须具有滥用优势地位的具体表现,并且要求中小企业接受明显不合理的交易条件并形成了拖欠账款事实。确属违法的,省级以上市场监管部门可以要求相关企业进行整改;拒不整改的,依照法律规定进行处理。根据案件情况,可以委托下级市场监管部门核查、调查。

市场监管总局:着力提升对网络不正当竞争行为的常态化监管水平

2026年3月30日 13:12
36氪获悉,市场监管总局发布关于进一步贯彻实施《中华人民共和国反不正当竞争法》的通知。其中提到,强化网络不正当竞争行为监管。统筹好活力和秩序的关系,完善网络竞争规则,着力提升对网络不正当竞争行为的常态化监管水平。运用好禁止侵害数据权益的专款规定、商业秘密保护规则,准确识别数据不正当竞争行为,平衡好数据保护和数据利用的关系,加大对人工智能等新兴产业的保护力度,有效保护数据要素各参与方的合法权益,维护数据市场竞争秩序。及时应对各类新型网络不正当竞争行为,有效规制利用数据和算法、技术、平台规则等实施不正当竞争行为。准确把握网络不正当竞争行为法律构成和实践特点,持续加大对流量劫持、恶意干扰、恶意不兼容、滥用平台规则实施虚假交易、虚假评价或者恶意退货等各类变相表现形式的甄别和监管执法,进一步加大对数字经济市场秩序的维护力度。

市场监管总局:着力防治平台经济、光伏、锂电池、新能源汽车等重点行业和领域“内卷式”竞争

2026年3月30日 13:11
36氪获悉,市场监管总局发布关于进一步贯彻实施《中华人民共和国反不正当竞争法》的通知。其中提到,综合整治“内卷式”竞争。综合运用各类反不正当竞争措施,着力防治平台经济、光伏、锂电池、新能源汽车等重点行业和领域“内卷式”竞争。精准辨识和依法查处平台企业无正当理由,利用搜索排名、经营评价、算法控制、限制流量、下架商品、增加费用、拖欠账期、中止交易、内部惩戒等手段,或在补贴、优惠、红包、折扣、“满减”、“买赠”、促销等活动中,强制或者变相强制平台内经营者以低于成本的价格销售商品,扰乱市场竞争秩序。
❌
❌