普通视图

发现新文章,点击刷新页面。
今天 — 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,可以在"数据新鲜度"和"请求次数"之间取得平衡。

静态生成 SSG:ISR 增量静态化实战

作者 Csvn
2026年3月30日 10:20

引言

在现代 Web 开发中,静态站点生成(SSG)已成为提升性能和 SEO 的关键技术。Next.js 推出的增量静态再生(ISR)更是革新了传统 SSG 的工作方式,让我们能够在保持静态页面性能优势的同时,实现内容的动态更新。本文将深入探讨 SSG 原理,并通过实战代码展示 ISR 的完整应用。

SSG 核心原理

SSG 在构建时预渲染页面,生成纯 HTML 文件。与传统 SSR 相比,SSG 的优势在于:

  • 极致性能:无需服务端计算,CDN 直接返回 HTML
  • SEO 友好:搜索引擎可直接抓取完整内容
  • 成本更低:静态托管费用远低于动态服务器

但传统 SSG 有个致命缺陷:内容更新需要重新构建整个站点。ISR 通过"按需再生"解决了这个问题。

ISR 工作机制

ISR 允许为每个页面设置 revalidate 时间(秒)。当用户访问过期页面时:

  1. 立即返回旧的静态页面(保证响应速度)
  2. 后台触发重新生成
  3. 下次请求返回新页面

这种" stale-while-revalidate "策略兼顾了性能与时效性。

Next.js ISR 实战

基础配置

// pages/blog/[slug].js
export async function getStaticPaths() {
  const posts = await fetchPosts();
  return {
    paths: posts.map(post => ({
      params: { slug: post.slug }
    })),
    fallback: 'blocking' // 关键:阻塞式回退
  };
}

export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug);
  
  return {
    props: { post },
    revalidate: 60 // 60 秒后过期,下次访问时再生
  };
}

export default function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

On-Demand Revalidation

对于需要即时更新的场景,Next.js 支持手动触发再生:

// pages/api/revalidate.js
export default async function handler(req, res) {
  // 验证请求来源(生产环境必须)
  if (req.query.secret !== process.env.REVALIDATION_TOKEN) {
    return res.status(401).json({ message: 'Invalid token' });
  }

  try {
    // 重新生成指定页面
    await res.revalidate('/blog/nextjs-features');
    
    // 或重新生成所有匹配路径
    // await res.revalidate('/blog');
    
    return res.json({ revalidated: true });
  } catch (err) {
    return res.status(500).send('Error revalidating');
  }
}

CMS 集成示例

当 CMS 发布新内容时,自动触发再生:

// CMS Webhook 处理
async function handleContentUpdate(content) {
  const slug = content.slug;
  
  // 生成新页面
  await fetch(`/api/revalidate?secret=${TOKEN}&path=/blog/${slug}`);
  
  // 更新列表页
  await fetch(`/api/revalidate?secret=${TOKEN}&path=/blog`);
}

性能优化技巧

1. 增量构建

// next.config.js
module.exports = {
  experimental: {
    isr: {
      memoryBasedWorkerCount: true, // 根据内存调整 worker 数
    }
  }
};

2. 智能缓存策略

// 根据内容类型设置不同 revalidate 时间
const REVALIDATE_TIMES = {
  news: 60,      // 新闻:1 分钟
  blog: 3600,    // 博客:1 小时
  docs: 86400,   // 文档:1 天
  about: false   // 关于页:永不过期
};

export async function getStaticProps({ params }) {
  const type = params.type;
  const content = await fetchContent(params.slug);
  
  return {
    props: { content },
    revalidate: REVALIDATE_TIMES[type]
  };
}

3. 回退页面优化

// components/FallbackSkeleton.js
export function BlogPostSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-5/6" />
    </div>
  );
}

// 在 getStaticPaths 中使用 fallback: 'blocking'
// 首次访问会等待生成,后续访问直接返回静态页面

部署注意事项

Vercel 部署

Vercel 原生支持 ISR,无需额外配置:

# 设置环境变量
vercel env add REVALIDATION_TOKEN production

自建部署

使用 next startnext-server,确保 Node 进程常驻:

# Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm ci && npm run build
EXPOSE 3000
CMD ["npm", "start"]

总结

ISR 代表了静态生成的未来方向:

特性 传统 SSG ISR SSR
首屏速度 ⭐⭐⭐ ⭐⭐⭐ ⭐⭐
内容时效 ⭐⭐⭐ ⭐⭐⭐
服务器成本 ⭐⭐⭐ ⭐⭐⭐
实现复杂度 ⭐⭐ ⭐⭐

对于内容型网站,ISR 是最佳选择。它让我们既能享受静态页面的性能红利,又能保持内容的及时更新。掌握 ISR,是现代前端工程师的必备技能。

🚨别再滥用 useEffect 了!90% React Bug 的根源就在这

作者 Sailing
2026年3月30日 10:05

你有没有发现一个现象:

  • 只要写 React,就离不开 useEffect
  • 数据变了 → 加 useEffect
  • 不知道逻辑放哪 → 塞 useEffect
  • 页面不更新 → 再加一层 useEffect

写到最后:

  • 组件里一半代码都是 useEffect
  • 无限循环、重复请求、莫名其妙重渲染、闭包陷阱满天飞
  • 改 Bug 比写功能还累

这篇文章只讲一件事:

useEffect 到底是什么?以及它为什么被 90% 的人用错?

先讲背景:useEffect 到底是干嘛的?

早期 React 组件,有一堆生命周期: componentDidMountcomponentDidUpdatecomponentWillUnmount… 逻辑散得到处都是,维护巨痛苦。

Hook 出来后,React 想解决一个问题:

把“跟渲染无关、跟外部交互”的逻辑,统一收拢。

于是有了 useEffect

它的定位非常清晰:处理副作用(Side Effect)

什么是副作用?就是跳出 React 渲染逻辑、去跟外部打交道的操作:

  • 请求 API 接口
  • 操作真实 DOM(比如聚焦第三方库)
  • 定时器、延时
  • 局事件监听(resize、keydown)
  • 本地存储、document.title
  • 同步外部系统(日志、埋点)

一句话总结:只有需要和“外部世界”同步时,才需要 useEffect。

致命误解:你把它当成了 “监听器”?

这是 React 新手最大的误区。

你以为它是:监听某个变量变化,然后执行逻辑。 但 React 的核心模型是:UI = f (state)(纯函数)

请死死记住这句话:useEffect 不是 “监听变量变化”,而是 “处理副作用”。

一旦滥用,React 内部发生了什么?

你写了一个逻辑,React 执行了一条死循环:

render (渲染) → effect (执行副作用) → setState (更新状态) → render (再次渲染) → effect ...

你以为只写了几行代码,其实你在 React 里开了一条高速公路,车多了自然堵车。

滥用 useEffect 的三大灾难

  1. 多余渲染暴增(性能杀手):一次逻辑触发多次渲染,页面卡顿、掉帧。
  2. 依赖链混乱(Bug 温床):依赖数组稍微不严谨,就陷入无限循环,或者闭包陷阱数据对不上。
  3. 逻辑碎片化(维护灾难):一个功能拆碎在多个不同的 useEffect 里,逻辑碎片化,谁敢动?

典型灾难链:

A 改 B → B 改 C → C 再改 A

你以为你在写逻辑,其实你在堆 Bug

这 4 种场景,绝对别用 useEffect

1. 计算状态 → 直接算,别存状态

// ❌ 错误:多此一举,引发重复渲染 
const [a, setA] = useState(1) 
const [b, setB] = useState(0) 

useEffect(() => { 
  setB(a * 2) 
}, [a])
// ✅ 正确:直接计算
const a = 1 
const b = a * 2 // 直接计算

能通过现有状态直接算出来的,就不要单独存状态,避免多余的渲染和逻辑。

2. 交互逻辑 → 写在事件处理函数里,不是 useEffect

// ❌ 错误:为了弹个提示,监听整个count
useEffect(() => {
  if (count === 10) alert('够了')
}, [count])
// ✅ 正确:点击时直接判断
const handleClick = () => {
  const newCount = count + 1
  setCount(newCount)
  if (newCount === 10) alert('够了')
}

用户主动触发的行为,不属于副作用同步,理应写在对应的事件处理函数中。

3. 初始化数据 → useState 初始值就能搞定

// ❌ 错误:多一次render
const [user, setUser] = useState(null)
useEffect(() => {
  setUser(currentUser)
}, [])
// ✅ 正确:一步到位,直接初始化
const [user] = useState(currentUser)

4. Props 同步 → 直接用 props,不要本地状态+effect

// ❌ 错误:典型反模式,数据来源不单一
useEffect(() => {
  setValue(props.value)
}, [props.value])
// ✅ 直接用 props
const { value } = props

🎯 useEffect 的唯一合法使用场景

只记 5 种合法场景,多一个都不用:

  1. 接口请求(记得必须带 AbortController 清理)
  2. 定时器 / 延时(必须 clear)
  3. 手动操作 DOM
  4. 全局事件监听(addEventListener 必须 remove)
  5. 同步外部系统(localStorage、title、埋点)

除此之外,能不用就不用。

结尾

很多人以为问题在 useEffect,其实问题在这里:

你有没有把组件当成“纯函数”?

通俗来讲,React 组件本该是纯函数:固定的 Props 和 State,就输出固定的 UI,不掺杂多余的副作用。

滥用 useEffect 就是强行打破这个规则,在渲染中乱加状态修改、异步逻辑,才引发各种 Bug 和性能问题。

你认为呢?Vue 的 Watch 是不是也是这个道理?欢迎在评论区一起讨论 ~~

从零到一:在 React 前端中集成 The Graph 查询 NFT 持有者数据实战

作者 竹林818
2026年3月30日 10:02

背景

上个月,我接手了一个 NFT 画廊项目的迭代开发。产品经理提了一个新需求:在项目首页,要展示我们平台核心 NFT 系列 CoolCats 的“持有者排行榜”,也就是按持有数量从多到少列出前 20 名钱包地址,并且要能实时更新。

我的第一反应是:这还不简单?直接用 ethers.js 或者 viem 去读合约的 Transfer 事件,然后自己累加计算不就行了?于是,我迅速写了个脚本,遍历从合约创建以来的所有 Transfer 事件。结果,脚本跑了快十分钟才出结果,而且消耗的 RPC 调用次数多得吓人。在真实的前端页面里,用户不可能等十分钟,我们的免费 RPC 节点也扛不住这种查询频率。

这时我才意识到问题的核心:对于需要聚合、筛选历史链上数据的复杂查询,直接在客户端通过 RPC 调用是行不通的。性能和成本都是大问题。我需要一个索引好的、类数据库的查询服务。这就是我决定使用 The Graph 的原因——它可以把链上数据索引到可快速查询的数据库中,并通过 GraphQL API 暴露出来。

问题分析

我的目标是查询 CoolCats NFT 合约(假设地址为 0x...)的所有持有者及其持有数量。最初,我尝试在 The Graph 的托管服务上找有没有现成的子图(Subgraph)。可惜,虽然有类似项目的子图,但要么不是针对这个特定合约,要么索引的数据字段不符合我的要求(比如只记录了交易,没聚合持仓)。

所以,路只有一条:自己为这个 NFT 合约创建并部署一个子图。这听起来有点吓人,我之前只用过现成的 GraphQL 端点。但拆解一下,其实就三步:

  1. 定义数据模式(Schema):明确我要索引和存储什么数据(例如 User 实体,包含 id(地址)和 balance)。
  2. 编写映射脚本(Mapping):用 AssemblyScript 写逻辑,告诉 The Graph 当监听到链上事件(如 Transfer)时,如何更新我定义的数据实体。
  3. 部署并查询:将子图部署到 The Graph 的托管服务或去中心化网络,然后从前端用 GraphQL 查询。

排查过程里,我卡住的第一个点是:如何准确处理 Transfer 事件,来正确增减用户的持仓?这里逻辑必须严谨,否则数据全错。比如,从“零地址” (0x000...) 转出代表铸造(Mint),接收方余额增加;转入“零地址”代表销毁(Burn),发送方余额减少。普通转账则是发送方减,接收方加。

核心实现

第一步:搭建子图项目与环境

首先,需要安装 Graph CLI。

npm install -g @graphprotocol/graph-cli
# 或者用 yarn global add @graphprotocol/graph-cli

然后,初始化一个子图项目。这里我选择从已有的 NFT 标准合约 ABI 开始,因为 CoolCats 遵循 ERC-721。

graph init --product hosted-service \
  --from-contract <CONTRACT_ADDRESS> \
  --network mainnet \
  --abi ./path/to/ERC721ABI.json \
  <GITHUB_USER>/<SUBGRAPH_NAME>

注意这个细节--product hosted-service 表示部署到 The Graph 的托管服务(免费,适合开发测试)。如果想部署到去中心化网络,需要用 --product subgraph-studio--from-contract 可以自动生成一些基础代码,但合约地址需要已经在 Etherscan 验证,否则 ABI 获取可能失败。稳妥起见,我直接用了本地保存的 ABI 文件。

初始化后,会生成一个标准的项目结构,关键文件是:

  • subgraph.yaml:子图清单,定义了数据源、合约地址、网络、映射文件等。
  • schema.graphql:数据模式定义文件。
  • src/mapping.ts:数据映射逻辑的入口文件。

第二步:定义数据模式(Schema)

schema.graphql 中,我定义了两个实体(Entity):

type User @entity {
  id: ID! # 用户的钱包地址,作为唯一ID
  balance: BigInt! # 当前持有的 NFT 数量
  tokens: [Token!]! @derivedFrom(field: "owner") # 关联持有的具体Token(可选)
}

type Token @entity {
  id: ID! # 格式为 “合约地址-tokenId”
  tokenId: BigInt!
  owner: User!
}

我的主要目标是排行榜,所以 User 实体是核心。balance 字段用于快速排序和查询。Token 实体是可选的,如果你还需要追踪每个 NFT 的归属,可以加上。@derivedFrom 表示 User.tokens 字段是从 Token.owner 字段反向派生出来的,不需要在映射中手动维护,这是一个非常方便的特性。

这里有个坑ID 类型在 GraphQL 中是字符串,但 The Graph 要求 id 字段必须唯一。对于 User,我直接用钱包地址(小写)作为 id。对于 Token,我组合了合约地址和 tokenId 来保证唯一性。

第三步:编写映射逻辑(Mapping)

这是最核心也最容易出错的部分。映射逻辑写在 src/mapping.ts 里,用的是 AssemblyScript(TypeScript 的子集)。

首先,要处理 Transfer 事件。我需要更新发送方(from)和接收方(to)的 User 实体的 balance

import { Transfer } from "../generated/CoolCats/CoolCats";
import { User, Token } from "../generated/schema";
import { Address } from "@graphprotocol/graph-ts";

export function handleTransfer(event: Transfer): void {
  // 1. 确保发送方和接收方的 User 实体存在
  let fromAddress = event.params.from.toHexString();
  let toAddress = event.params.to.toHexString();
  let tokenId = event.params.tokenId;

  let fromUser = User.load(fromAddress);
  let toUser = User.load(toAddress);

  // 处理发送方(如果不是零地址)
  if (!isZeroAddress(fromAddress)) {
    if (fromUser == null) {
      // 理论上不应该发生,但创建以防万一
      fromUser = new User(fromAddress);
      fromUser.balance = BigInt.fromI32(0);
    }
    // 发送方减少一个NFT
    fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1));
    fromUser.save();
  }

  // 处理接收方(如果不是零地址)
  if (!isZeroAddress(toAddress)) {
    if (toUser == null) {
      toUser = new User(toAddress);
      toUser.balance = BigInt.fromI32(0);
    }
    // 接收方增加一个NFT
    toUser.balance = toUser.balance.plus(BigInt.fromI32(1));
    toUser.save();
  }

  // 2. 更新 Token 实体的所有者(可选,如果你定义了Token实体)
  let tokenCompositeId = event.address.toHexString() + '-' + tokenId.toString();
  let token = Token.load(tokenCompositeId);
  if (token == null) {
    token = new Token(tokenCompositeId);
    token.tokenId = tokenId;
  }
  // 如果转入零地址,代表销毁,owner可以指向一个“销毁地址”实体或清空。这里简单指向零地址的User。
  token.owner = isZeroAddress(toAddress) ? toAddress : toAddress;
  token.save();
}

function isZeroAddress(address: string): boolean {
  return address == '0x0000000000000000000000000000000000000000';
}

踩坑预警BigInt 运算必须使用 The Graph 提供的 .plus(), .minus() 方法,不能用 +- 操作符,否则编译会报错。另外,一定要小心零地址的处理,它代表资产铸造或销毁,不应该为其创建 User 实体。

第四步:部署子图并获取 API 端点

  1. 在 The Graph 托管服务网站创建账户和子图
  2. 在本地终端登录并部署
    graph auth --product hosted-service <ACCESS_TOKEN>
    yarn deploy
    
    部署命令会编译 AssemblyScript、上传子图定义并开始同步链上数据。同步时间取决于合约历史事件的数量,可能需要几十分钟到几小时。
  3. 同步完成后,在托管服务的控制台,你会获得一个类似这样的 GraphQL API 端点: https://api.thegraph.com/subgraphs/name/你的用户名/你的子图名称

第五步:在前端 React 项目中查询

现在,就可以在前端用任何 GraphQL 客户端查询数据了。我选择使用 graphql-request,因为它轻量简单。

npm install graphql-request graphql

然后,创建一个服务文件 src/services/theGraph.ts

import { GraphQLClient, gql } from 'graphql-request';

// 替换成你部署后的真实端点
const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/your-username/coolcats-holders';

const client = new GraphQLClient(GRAPHQL_ENDPOINT);

export interface UserRank {
  id: string; // 钱包地址
  balance: string; // 持仓数量,GraphQL 返回的是字符串
}

export async function fetchTopHolders(limit: number = 20): Promise<UserRank[]> {
  const query = gql`
    query GetTopHolders($first: Int!) {
      users(
        first: $first,
        orderBy: balance,
        orderDirection: desc,
        where: { balance_gt: 0 } # 只查询持仓大于0的
      ) {
        id
        balance
      }
    }
  `;

  try {
    const data: any = await client.request(query, { first: limit });
    // 转换类型,balance 从字符串转为数字(如果需要)
    return data.users.map((user: any) => ({
      id: user.id,
      balance: user.balance,
    }));
  } catch (error) {
    console.error('Error fetching data from The Graph:', error);
    return [];
  }
}

最后,在 React 组件中使用:

import React, { useEffect, useState } from 'react';
import { fetchTopHolders, UserRank } from './services/theGraph';

function HolderLeaderboard() {
  const [holders, setHolders] = useState<UserRank[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const loadData = async () => {
      setLoading(true);
      const data = await fetchTopHolders(20);
      setHolders(data);
      setLoading(false);
    };
    loadData();
  }, []);

  if (loading) return <div>Loading leaderboard...</div>;

  return (
    <div>
      <h2>CoolCats Top Holders</h2>
      <table>
        <thead>
          <tr>
            <th>Rank</th>
            <th>Address</th>
            <th>Balance</th>
          </tr>
        </thead>
        <tbody>
          {holders.map((holder, index) => (
            <tr key={holder.id}>
              <td>{index + 1}</td>
              <td>{`${holder.id.slice(0, 6)}...${holder.id.slice(-4)}`}</td>
              <td>{holder.balance}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default HolderLeaderboard;

完整代码

由于子图项目文件较多,这里提供最核心的 schema.graphqlmapping.ts 的完整代码,以及前端查询服务的代码。

1. 子图 schema.graphql (完整)

type User @entity {
  id: ID! # 用户钱包地址
  balance: BigInt! # 持仓数量
  tokens: [Token!]! @derivedFrom(field: "owner") # 持有的NFT列表
}

type Token @entity {
  id: ID! # 合约地址-tokenId
  tokenId: BigInt!
  owner: User! # NFT当前所有者
}

2. 子图 src/mapping.ts (完整)

import { Transfer } from "../generated/CoolCats/CoolCats";
import { User, Token } from "../generated/schema";
import { BigInt } from "@graphprotocol/graph-ts";

export function handleTransfer(event: Transfer): void {
  let from = event.params.from;
  let to = event.params.to;
  let tokenId = event.params.tokenId;
  let fromAddress = from.toHexString();
  let toAddress = to.toHexString();

  // 更新发送方余额 (非零地址)
  if (!isZeroAddress(fromAddress)) {
    let fromUser = User.load(fromAddress);
    if (fromUser == null) {
      // 防御性创建,理论上在第一次转出时应该已存在
      fromUser = new User(fromAddress);
      fromUser.balance = BigInt.fromI32(0);
    }
    fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1));
    // 如果余额减到0,可以选择保留或删除实体。排行榜查询时用 where balance_gt: 0 过滤即可。
    fromUser.save();
  }

  // 更新接收方余额 (非零地址)
  if (!isZeroAddress(toAddress)) {
    let toUser = User.load(toAddress);
    if (toUser == null) {
      toUser = new User(toAddress);
      toUser.balance = BigInt.fromI32(0);
    }
    toUser.balance = toUser.balance.plus(BigInt.fromI32(1));
    toUser.save();
  }

  // 更新Token所有者信息
  let tokenCompositeId = event.address.toHexString() + '-' + tokenId.toString();
  let token = Token.load(tokenCompositeId);
  if (token == null) {
    token = new Token(tokenCompositeId);
    token.tokenId = tokenId;
  }
  // 注意:如果转入零地址(销毁),owner指向零地址的User实体(不存在或余额为0)
  token.owner = toAddress; // 直接存储地址字符串作为关联ID
  token.save();
}

function isZeroAddress(address: string): boolean {
  return address == '0x0000000000000000000000000000000000000000';
}

3. 前端查询服务 src/services/theGraph.ts (完整)

import { GraphQLClient, gql } from 'graphql-request';

const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/your-username/coolcats-subgraph';

const client = new GraphQLClient(GRAPHQL_ENDPOINT);

export interface UserRank {
  id: string;
  balance: string;
}

export async function fetchTopHolders(limit: number = 20): Promise<UserRank[]> {
  const query = gql`
    query GetTopHolders($first: Int!) {
      users(
        first: $first,
        orderBy: balance,
        orderDirection: desc,
        where: { balance_gt: "0" }
      ) {
        id
        balance
      }
    }
  `;

  try {
    const data: any = await client.request(query, { first: limit });
    return data.users;
  } catch (error) {
    console.error('Error fetching from The Graph:', error);
    throw error; // 或者返回空数组,根据业务处理
  }
}

踩坑记录

  1. TypeError: e.plus is not a function:在映射文件中,我最初用了 fromUser.balance -= 1。AssemblyScript 中 BigInt 必须使用其自身的方法 .plus(), .minus(), .times(), .div()。改成 fromUser.balance = fromUser.balance.minus(BigInt.fromI32(1)) 解决。

  2. 子图同步卡在某个区块不动:部署后,子图同步状态一直停留在很早的区块。原因是我的映射函数 handleTransfer 里出现了运行时错误(比如访问了空对象的属性),导致索引器在该区块崩溃。解决方法是去 The Graph 托管服务的日志页面查看错误信息,根据提示修复映射逻辑,然后重新部署。重要:修复后需要“重新部署”而非“重新同步”,因为代码变更需要新的部署版本。

  3. 查询结果 balance 为字符串:GraphQL 中 BigInt 类型会以字符串形式返回。前端如果需要进行数值比较或计算,需要手动转换,例如 Number(balance)BigInt(balance)。注意 JavaScript 中大数的精度问题。

  4. 零地址处理不当导致数据错误:我最初没有过滤零地址,为 0x000... 也创建了 User 实体,导致排行榜上出现一个持有量巨大且奇怪的地址。通过添加 isZeroAddress 判断,并在查询时使用 where: { balance_gt: "0" } 过滤,解决了这个问题。

小结

这次实战让我彻底搞懂了如何从零开始,为一个智能合约创建 The Graph 子图,并集成到前端应用。核心收获是:将复杂的链上数据聚合逻辑转移到链下的索引服务中,是解决前端性能瓶颈的关键。现在,我们的 NFT 排行榜查询从十分钟变成了毫秒级。下一步,我可以探索更复杂的查询,比如分页、根据时间范围筛选持仓变化,甚至是将多个合约的数据关联到一个子图中进行联合查询。

【LeetCode 刷题系列|第 3 篇】详解大数相加:从模拟竖式到简洁写法的优化之路🔢

2026年3月30日 09:49

🔢 前言

Hello~大家好,我是秋天的一阵风

今天要攻克的是 LeetCode 上的经典 大数计算 题 ——「字符串相加」(题号 415)。

这道题的核心场景是「大数相加」:输入的两个非负整数以字符串形式存储(长度最长可达 5100 位),根本无法直接转成 Number 或 BigInt 类型计算,本质是考察手动模拟大数竖式加法的能力。

它和前两篇的「盛最多水」「接雨水」不同,重点不是算法复杂度优化,而是处理「进位、长度对齐、末尾残留进位」这些大数计算的关键细节,非常适合夯实字符串操作和边界处理思维。

话不多说,咱们一步步拆解,让你彻底掌握大数相加的核心逻辑~

一、LeetCode 大数相加(字符串版)题目详情

1. 题目描述

给定两个非负整数 num1num2,它们以字符串形式表示(即大数),返回它们的和也以字符串形式表示。说明

  • 你不能使用任何内置的 BigInteger 库或直接将输入转换为整数形式(核心限制,凸显大数场景);
  • num1num2 的长度都小于 5100(明确大数规模);
  • num1num2 都只包含数字 0-9
  • num1num2 都不包含前导零(除了数字 0 本身)。

题目链接415. 字符串相加 - 力扣(LeetCode)

2. 示例演示

  • 输入:num1 = "11", num2 = "123"
  • 输出:"134"
  • 解释:11 + 123 = 134,模拟竖式相加:个位 1+3=4,十位 1+2=3,百位 0+1=1,拼接结果为 "134"(小型大数场景,理解基础逻辑)。
  • 输入:num1 = "456", num2 = "77"
  • 输出:"533"
  • 解释:个位 6+7=13(留 3 进 1),十位 5+7+1=13(留 3 进 1),百位 4+0+1=5,结果为 "533"(含进位的典型场景)。
  • 输入:num1 = "999999999999999999", num2 = "1"
  • 输出:"1000000000000000000"
  • 解释:超长大数相加,末尾进位贯穿所有位,最终需在最前方补 1(大数计算核心边界场景)。
  • 输入:num1 = "0", num2 = "0"
  • 输出:"0"
  • 解释:两个零相加,结果仍为零,注意不能返回 "00" 这类前导零(特殊边界场景)。

3. 难度级别

🟢 简单 → 🔵 中等(实际考察):题目逻辑本身不复杂,但大数场景下的「进位传递」「长度对齐补零」「末尾残留进位」这三个点极易出错,核心是复刻竖式加法的完整流程,确保覆盖所有大数计算的边界情况。

二、解题思路大剖析

1. 基础解法:模拟大数竖式相加

基础解法的核心思路就是复刻大数竖式加法的手工流程:因为是大数,无法直接转数字计算,所以从两个字符串的「末尾(个位)」开始,逐位提取数字相加,同步记录当前位结果和进位,最后将结果反转(因计算顺序是从低位到高位)。

核心步骤:

  1. 指针初始化:i 指向 num1 末尾(个位),j 指向 num2 末尾(个位),适配大数的低位到高位计算逻辑;

  2. 进位初始化:carry = 0(初始无进位,大数相加的进位可能贯穿多位);

  3. 结果容器:用数组 res 存储每一位结果(大数拼接频繁,数组比字符串高效);

  4. 循环计算(覆盖大数所有位 + 残留进位):只要 i >= 0(num1 未处理完)、j >= 0(num2 未处理完)或 carry > 0(仍有进位),就继续:

    • 提取当前位数字:num1 当前位为 i >= 0 ? num1[i] - '0' : 0(大数长度不一致时,短数高位补 0),num2 同理;
    • 计算当前位总和:sum = 位1 + 位2 + carry(必须包含前一位进位,大数进位不可遗漏);
    • 提取当前位结果:sum % 10(取个位,如 sum=13 则当前位为 3);
    • 更新进位:carry = Math.floor(sum / 10)(取十位,如 sum=13 则进位为 1,可能传递到下一位);
    • 存入结果:将当前位结果推入 res 数组;
    • 指针左移:i--j--,处理大数的更高位;
  5. 结果整理:res 中是「个位→高位」的顺序,反转后拼接成字符串(大数的高位在前、低位在后)。

分步拆解演示(以大数输入 num1="9999", num2="123" 为例):

  • 初始状态:i=3(num1[3]='9'),j=2(num2[2]='3'),carry=0res=[]

  • 第 1 轮(个位):

    • 位 1=9,位 2=3 → sum=9+3+0=12;
    • 当前位:12%10=2 → res=[2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=2,j=1;
  • 第 2 轮(十位):

    • 位 1=9(num1 [2]='9'),位 2=2(num2 [1]='2') → sum=9+2+1=12;
    • 当前位:12%10=2 → res=[2,2];
    • 进位:12/10=1 → carry=1;
    • 指针:i=1,j=0;
  • 第 3 轮(百位):

    • 位 1=9(num1 [1]='9'),位 2=1(num2 [0]='1') → sum=9+1+1=11;
    • 当前位:11%10=1 → res=[2,2,1];
    • 进位:11/10=1 → carry=1;
    • 指针:i=0,j=-1;
  • 第 4 轮(千位):

    • 位 1=9(num1 [0]='9'),位 2=0(j<0 补 0) → sum=9+0+1=10;
    • 当前位:10%10=0 → res=[2,2,1,0];
    • 进位:10/10=1 → carry=1;
    • 指针:i=-1,j=-1;
  • 第 5 轮(残留进位):

    • 位 1=0,位 2=0 → sum=0+0+1=1;
    • 当前位:1%10=1 → res=[2,2,1,0,1];
    • 进位:1/10=0 → carry=0;
    • 指针:i=-1,j=-1;
  • 循环终止:i<0、j<0 且 carry=0;

  • 反转 res:[2,2,1,0,1] → [1,0,1,2,2] → 拼接成字符串 "10122"(9999+123=10122,符合大数计算预期)。

JavaScript 代码实现(基础解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1; // 指向num1末尾(大数个位)
    let j = num2.length - 1; // 指向num2末尾(大数个位)
    let carry = 0; // 进位,大数相加可能跨多位传递
    const res = []; // 存储每一位结果,避免大数字符串频繁拼接
    
    // 循环条件:覆盖大数所有位 + 残留进位
    while (i >= 0 || j >= 0 || carry > 0) {
        // 提取当前位数字(大数长度不一致时补0),字符转数字(减'0')
        const digit1 = i >= 0 ? num1[i] - '0' : 0;
        const digit2 = j >= 0 ? num2[j] - '0' : 0;
        
        // 计算当前位总和(含前一位进位)
        const sum = digit1 + digit2 + carry;
        // 当前位结果:sum的个位数
        const currentDigit = sum % 10;
        // 更新进位:sum的十位数(向下取整,可能为0或1)
        carry = Math.floor(sum / 10);
        
        // 推入结果数组(大数低位→高位顺序)
        res.push(currentDigit);
        
        // 指针左移,处理大数更高位
        i--;
        j--;
    }
    
    // 反转数组→拼接字符串(大数高位→低位顺序)
    return res.reverse().join('');
};

// 测试用例验证(覆盖大数、进位、边界场景)
console.log(addStrings("11", "123")); // 输出"134",符合预期
console.log(addStrings("9999", "123")); // 输出"10122",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期
console.log(addStrings("0", "0")); // 输出"0",符合预期

基础解法的优缺点:

  • 优点:完全贴合大数竖式加法逻辑,步骤清晰,覆盖所有大数场景的边界(超长长度、跨位进位、残留进位),面试中写出来稳定性高,不易出错;
  • 缺点:代码有少量冗余变量(如 digit1 digit2),但不影响可读性,大数计算场景下时间和空间已接近最优,无明显可优化点。

2. 优化解法:代码简洁化

优化解法的核心逻辑和基础解法完全一致(仍是模拟大数竖式),仅在代码写法上精简,减少冗余变量,让代码更紧凑(面试中能体现对大数计算逻辑的熟练掌握)。

优化点:

  • 合并变量声明:将 i j carry 合并声明,减少代码行数;
  • 嵌入位计算:将 digit1 digit2 的提取直接嵌入 sum 计算中,避免冗余变量;
  • 简化循环条件:carry 为 0 时会自动终止,无需写 carry > 0(因 0 为 falsy 值)。

JavaScript 代码实现(优化解法):

/**
 * @param {string} num1
 * @param {string} num2
 * @return {string}
 */
var addStrings = function(num1, num2) {
    let i = num1.length - 1, j = num2.length - 1, carry = 0;
    const res = [];
    
    while (i >= 0 || j >= 0 || carry) {
        // 直接计算当前位总和(嵌入大数位提取+补0逻辑)
        const sum = (i >= 0 ? num1[i] - '0' : 0) + (j >= 0 ? num2[j] - '0' : 0) + carry;
        res.push(sum % 10); // 当前位结果
        carry = Math.floor(sum / 10); // 更新进位
        i--;
        j--;
    }
    
    return res.reverse().join('');
};

// 测试用例验证
console.log(addStrings("456", "77")); // 输出"533",符合预期
console.log(addStrings("999999999999999999", "1")); // 输出"1000000000000000000",符合预期

优化解法的特点:

  • 逻辑不变:完全遵循大数竖式加法规则,覆盖所有边界场景;
  • 代码精炼:行数减少,无冗余变量,面试时书写速度更快;
  • 可读性强:变量名自解释,面试官能快速理解大数计算逻辑;
  • 复杂度不变:时间和空间复杂度与基础解法一致,属于「写法优化」而非「算法优化」。

三、总结

1. 核心逻辑

大数相加(字符串版)的本质是「模拟手工竖式加法」,核心要点有三个,缺一不可:

  1. 「从后往前算」:大数的低位在字符串末尾,需从末尾开始逐位处理;
  2. 「补零对齐」:大数长度不一致时,短数的高位补 0,避免索引越界,确保每一位都能对应相加;
  3. 「进位不遗漏」:每一步相加必须带上前一位的进位,且循环结束前需检查是否有残留进位(如 999+1 的最后进位 1)。

2. 最后

今天的「大数相加(字符串版)」就讲解到这里啦!相信大家已经吃透了「模拟竖式 + 进位传递」的核心逻辑,不管是基础解法还是优化解法,都能轻松应对面试中的大数场景。如果在测试超长大数、全 9 数字相加等特殊情况时遇到问题,或者有更巧妙的实现思路,欢迎在评论区留言讨论~

下一篇,咱们会继续攻克 LeetCode 高频题(「三数之和」),关注我,刷题路上不迷路!咱们下期再见~ 👋

《Vue3+TS+Vite 高效编程与优化实践》专栏收尾

作者 wuhen_n
2026年3月30日 09:42

当你读完这篇文章时,说明我们已经共同走过了一段不短的旅程。从 Vue 3 的核心思想到 TypeScript 的深度集成;从性能优化的底层原理到实战案例的完整记录,我们一起探索了 Vue 3 + TypeScript + Vite 技术栈的方方面面。本篇文章,我想和你们聊聊这段旅程的收获,以及未来的方向。

回顾旅程:我们走过了什么?

第一部分:Vue 3 + TypeScript 核心编程思想与高效逻辑复用

在这一部分,我们从 Composition API 出发,理解了为什么它是逻辑复用的未来。我们深入响应式系统的内部,知道了 refreactive 该何时使用,也学会了用 toRefstoRef 保持解构后的响应性。我们剖析了 computed 的缓存哲学,掌握了 watchwatchEffect 的精准监听技巧。最后,我们用 TypeScript 为组合式函数赋予了“钢筋铁骨”——让复用逻辑不仅灵活,而且类型安全。

核心收获

  • 理解 Composition API 的设计哲学与 Mixin 的对比优势
  • 掌握响应式系统的底层原理(Proxy、依赖收集、触发更新)
  • 学会组织可复用的组合式函数(分层思想、单一职责)
  • 用 TypeScript 泛型约束构建类型安全的逻辑复用

第二部分:Vue 3 + TS 组件化高效开发

在这一部分,我们学习了如何设计一个高内聚、低耦合的 Vue 组件,掌握了 Props、事件、插槽的进阶用法。我们用 TypeScript 为组件的 Props 和事件保驾护航,探索了 v-model 的多重绑定、动态组件的 keep-alive 缓存策略,以及自定义指令的 DOM 抽象能力。

核心收获

  • 高内聚低耦合的组件设计原则(Props 设计、事件发射)
  • 类型安全的 Props(PropType)与事件声明
  • 灵活的插槽分发机制(默认、具名、作用域)
  • 动态组件的 keep-alive 缓存策略
  • 自定义指令的封装技巧与类型编写

第三部分:网络层与数据流优化

在这一部分,我们封装了 Axios 请求库,实现了取消重复请求、请求重试、超时处理等核心能力。我们设计了多级缓存策略(内存缓存、持久化缓存),让应用“快如闪电”。我们深入 Pinia 的内部,理解了如何定义类型安全的 Store,并用 storeToRefs 避免响应式性能陷阱。最后,我们掌握了 Vue Router 的进阶用法:路由懒加载、导航守卫、元信息的高效运用。

核心收获

  • Axios 二次封装与请求策略(防抖节流、重试、取消)
  • 多级缓存架构设计(Map/WeakMap + localStorage/indexedDB)
  • Pinia 类型安全与性能优化(storeToRefs 精准订阅)
  • Vue Router 进阶实战(懒加载、守卫、元信息)

第四部分:Vue 3 应用运行时性能优化实战

在这一部分,我们攻克了虚拟列表的难题,实现了成千上万条数据的不卡顿渲染。我们掌握了 v-oncev-memo 的精髓,让渲染“躺平”。我们用 shallowRefshallowReactive 应对大数据量和大对象,学会了事件监听器的销毁与内存泄漏排查。我们对比了函数式组件与有状态组件的性能差异,并用异步组件与 Suspense 优雅地处理加载状态。

核心收获

  • 虚拟列表实现原理(可视区计算、缓冲区策略)
  • 浅层响应式的妙用(shallowRef 减少响应式开销)
  • 内存泄漏排查与修复(事件监听、定时器、全局变量)
  • 函数式组件的适用场景(无状态、高频率渲染)
  • Suspense 的加载状态管理与错误处理

第五部分:Vite 构建优化与工程化配置

在这一部分,我们深入 Vite 的核心原理,理解了 ESM 带来的开发时“瞬移”体验。我们解决了开发环境启动慢、热更新慢的痛点,掌握了依赖预构建的 includeexclude 艺术。我们优化了生产环境构建,用 manualChunks 实现智能拆包,用图片压缩和 Gzip/Brotli 压缩减少传输体积。我们配置了 Vite 代理解决跨域,用 vite-plugin-mock 快速 Mock 数据。最后,我们用 ESLint、Prettier、Husky、lint-staged 建立了自动化的高效前端工作流。

核心收获

  • Vite 核心原理与 ESM 机制(no-bundle、预构建)
  • 开发环境优化:依赖预构建的 optimizeDeps 配置
  • 生产环境优化:manualChunks 拆包、图片压缩、Gzip/Brotli
  • 代理与 Mock 协同策略(解决跨域 + 并行开发)
  • 自动化工作流搭建(代码规范、Git 钩子)

第六部分:图片优化专题系列

在这一部分,我们深入图片优化的方方面面:从 Vite 构建层面的压缩与格式转换,到 Vue 组件中的懒加载与渐进式加载;从响应式图片的 srcsetpicture 实践,到 CDN 图片服务的动态参数优化。我们不仅掌握了技术原理,更学会了如何在实际项目中落地。

核心收获

  • 图片压缩原理与构建集成(Sharp/Imagemin、WebP/AVIF 转换)
  • 懒加载与渐进式加载实现(IntersectionObserver、LQIP 模糊占位)
  • 响应式图片的工程化实践(srcset/sizespicture 艺术指导)
  • CDN 图片服务与动态优化(阿里云 OSS/七牛云参数拼接)
  • 电商 SKU 图片切换的秒级加载优化实战

第七部分:测试与质量保障

在这一部分,我们用 Vitest 为关键组件和组合式函数编写单元测试,保证了重构的效率。我们深入组件测试策略,掌握了如何测试 Props、事件和插槽,确保组件行为符合预期。

核心收获

  • Vitest 配置与集成(JSDOM、Vue 插件)
  • 组合式函数测试(withSetup 模式、Mock 依赖)
  • 组件测试:Props、事件、插槽的验证
  • Mock 策略与依赖隔离(网络请求、第三方库)

第八部分:实战篇 - 解决真实场景的疑难杂症

在这一部分,我们用三个完整的案例分析,串联了专栏的所有知识点:

  • 复杂表单的响应式性能优化:从 3.5 秒到 0.8 秒,涉及 shallowRef、表单拆分、联动优化
  • 大屏可视化项目的卡顿排查与解决:从 15fps 到 60fps,涉及图表优化、内存泄漏、动画性能
  • 后台管理页面的全链路优化记录:从 55 分到 89 分,涉及路由懒加载、拆包、缓存、长任务拆分

最后,我们在“终局之战”这一篇文章中,搭建了全链路性能体检与监控体系,让性能优化从“救火”变成“防火”。

核心收获

  • 复杂表单优化方法论(拆分、浅层响应式、防抖)
  • 大屏可视化卡顿排查流程(帧率监控、内存分析、渲染路径)
  • 全链路性能优化框架(网络-构建-渲染-运行时)
  • 性能监控与告警体系(Lighthouse CI、自定义埋点)
  • 持续优化的闭环思维(测量→分析→优化→验证)

学习建议:如何消化这些知识?

实践是最好的老师

专栏中的代码示例,我强烈建议你亲手敲一遍。不要复制粘贴,而是要理解每一行代码的含义。遇到不理解的地方,打开 DevTools 调试,看看运行时的状态。学习路径建议:

  • 第一遍:跟着敲代码,跑通示例
  • 第二遍:修改代码,观察变化
  • 第三遍:不看示例,自己实现
  • 第四遍:教给别人,检验理解

一定要建立自己的知识体系

前端知识更新很快,但底层的原理是不变的。我强烈建议你建立一个“性能优化检查清单”,把专栏中提到的优化点整理成可执行的条目。 个人性能优化清单示例:

const myPerformanceChecklist = {
  // 网络层
  network: [
    '请求合并 (Promise.all)',
    '数据预加载 (prefetch)',
    'API 缓存 (5分钟内存缓存)'
  ],
  
  // 构建层
  build: [
    '路由懒加载 (() => import)',
    '代码分割 (manualChunks)',
    '图片压缩 (WebP/AVIF + 阈值内联)'
  ],
  
  // 渲染层
  render: [
    '虚拟滚动 (>500条数据)',
    'v-memo 缓存列表项',
    'keep-alive 缓存页面'
  ],
  
  // 运行时
  runtime: [
    '防抖节流 (搜索/滚动)',
    'Web Worker (大数据处理)',
    '长任务拆分 (requestIdleCallback)'
  ]
}

从“会用”到“懂原理”

不要满足于“知道怎么用”,而是要问自己“为什么这么用”。比如:

  • 为什么 shallowRefref 快?—— 因为它只跟踪 .value 的变化,不进行深层代理
  • 为什么 v-memo 能跳过渲染?—— 因为它缓存了虚拟节点的比对结果
  • 为什么虚拟滚动能提升性能?—— 因为它将 DOM 节点数量从 O(n) 降到 O(可视区行数)

理解了原理,你就能举一反三,在任何场景下做出正确的选择。

建立性能基准

优化前先测量,优化后要验证。没有数据的优化是盲目的。在你的项目中建立性能基准,每次迭代都对比指标变化。

// 项目性能基准
const baseline = {
  // 加载指标
  FCP: 1800,      // 毫秒
  LCP: 2500,
  TTFB: 600,
  
  // 交互指标
  FID: 100,
  INP: 200,
  
  // 稳定性
  CLS: 0.1,
  
  // 资源体积
  bundleSize: 500 * 1024  // 字节
}

持续学习:前端性能优化的未来趋势

新的 Web 标准

技术 趋势 影响
View Transitions API 原生页面过渡动画 更流畅的页面切换体验
Speculation Rules API 智能预加载 更快的页面导航(瞬时加载)
Shared Element Transitions 共享元素过渡 更自然的动画体验(SPA/MPA 统一)
Compression Dictionary Transport 更好的压缩算法 更小的传输体积(ZSTD 支持)

框架层面的演进

Vue 生态的未来方向:

  • Vapor Mode:无虚拟 DOM 的编译策略(类 Solid.js)
  • 更细粒度的响应式优化(精确到属性级别的更新)
  • 更好的 Tree Shaking 支持(减少运行时体积)
  • 更智能的代码分割(基于使用频率的动态分割)

AI 辅助性能优化

AI 正在改变编程和性能优化的方式:

  • 自动识别性能瓶颈:AI 分析 Lighthouse 报告,自动定位问题代码
  • 智能推荐优化方案:根据项目特征推荐最合适的优化策略
  • 自动化性能测试:AI 生成测试用例,覆盖各种设备和网络条件
  • 预测性能回归:在代码提交前预测对性能指标的影响

边缘计算与性能

传统架构要求的是:用户 → CDN → 源服务器

边缘架构强调:用户 → 边缘节点 → 源服务器

边缘计算的收益:

  • 更低的 TTFB(距离用户更近)
  • 更快的首屏渲染(边缘渲染 HTML)
  • 更好的全球用户体验(任意地区 <100ms 延迟)

性能优化的新战场

移动端性能

  • 5G 时代的弱网优化(带宽波动处理)
  • 低端设备的降级策略(根据设备性能动态调整)
  • 离线优先架构(Service Worker 缓存策略)

交互性能

  • INP (Interaction to Next Paint) 指标
  • 更精准的用户感知测量(真实用户监控)
  • 实时交互反馈优化(乐观更新、骨架屏)

资源加载:

  • 103 Early Hints 协议(提前预连)
  • 更智能的预加载策略(基于用户行为预测)
  • 动态资源调度(优先级队列)

互动交流:期待听到你的声音

专栏的终点,学习的起点

这 38 篇文章只是我经验的一部分,真正的学习在你接下来的项目中。当你在实际开发中遇到性能问题,欢迎回到这里查阅相关章节。

欢迎提问与反馈

如果在实践中有任何问题,或者对某些内容有疑问,欢迎在评论区留言。我会持续关注并解答。

你最想深入探讨的话题:

  • 1. 虚拟列表的完整实现与优化(动态高度、增量渲染)
  • 2. 微前端架构的性能优化(应用隔离、共享依赖)
  • 3. 移动端性能优化专题(触屏交互、内存限制)
  • 4. 首屏渲染的极致优化(SSR、边缘渲染、预渲染)
  • 5. 大文件上传与下载优化(断点续传、并发控制)
  • 6. WebAssembly 在性能优化中的应用(计算密集型任务)
  • 7. 其他:__________

分享你的经验

如果你有好的优化案例,也欢迎分享出来。知识的价值在于流动,经验的分享能让更多人受益。

写在最后

前端开发是一门手艺,而性能优化是这门手艺中最能体现功力的部分。

记得我刚入行时,导师说过一句话:“一个页面的快,不是靠一个优化点,而是靠无数个细节的积累。”这句话我一直记在心里。

这个专栏里的每一个技巧、每一种模式,都是前人踩过坑之后的经验总结。我希望你能把这些知识内化成自己的能力,而不是仅仅存在收藏夹里。

未来当你优化出一个流畅的页面,用户说“这个页面真快”的时候,你会明白,这就是我们做技术最大的成就感。

愿你的页面永远流畅,愿你的代码永远优雅。

保持好奇,持续精进。

附录:专栏完整文章索引

第一部分:Vue3 + TypeScript 核心编程思想与高效逻辑复用

序号 标题 文章简介
01 告别 Options API:为什么 Composition API 是逻辑复用的未来? 从 Options API 到 Composition API 的演进,解析组合式 API 如何解决逻辑复用、代码组织、TS 类型支持等痛点
02 setup 的艺术:如何组织我们的组合式函数? 讲解 setup 函数设计原则、组合式函数拆分规范、代码组织最佳实践,让组件逻辑更清晰
03 响应式探秘:ref vs reactive,我该选谁? 对比 ref 与 reactive 底层原理、使用差异、适用场景,给出通用选型标准
04 高效的数据解构:用 toRefs 和 toRef 保持响应性 解决 reactive 解构丢失响应式问题,详解 toRef/toRefs 用法、原理与实战场景
05 computed 的缓存哲学:如何避免不必要的重复计算? 剖析 computed 缓存机制、依赖追踪逻辑,讲解如何避免滥用与重复计算
06 watch 与 watchEffect:精准监听,避免副作用滥用 对比 watch 与 watchEffect 的监听机制、使用场景,规范副作用编写
07 TypeScript 深度加持:让我们的组合式函数拥有 “钢筋铁骨” 为组合式函数完善 TS 类型定义,提升类型安全、开发提示与代码健壮性

第二部分:Vue3 + TS 组件化高效开发

序号 标题 文章简介
08 组件设计原则:如何设计一个高内聚、低耦合的 Vue 组件 讲解 Vue 组件拆分、职责划分、Props 设计、耦合度优化的核心原则
09 TypeScript 强力护航:PropType 与组件事件类型的声明 使用 PropType 规范组件 Props 类型,完整声明组件事件,强化 TS 校验
10 v-model 的进阶用法:搞定复杂的父子组件数据通信 讲解自定义 v-model、多绑定值、修饰符,实现复杂双向绑定
11 插槽的作用域与分发:如何让组件更灵活、可定制? 详解作用域插槽、具名插槽、动态插槽,实现高定制化组件
12 动态组件与 keep-alive:如何优化页面切换体验与性能? 动态组件切换、keep-alive 缓存策略、include/exclude 使用与性能优化
13 自定义指令:为 DOM 操作提供高效的抽象入口 封装自定义指令简化 DOM 操作,实现逻辑复用,替代冗余 ref 操作

第三部分:网络层与数据流优化

序号 标题 文章简介
14 VUE3 中的 Axios 二次封装与请求策略 Axios 请求拦截、响应处理、错误捕获、请求策略封装,简化接口调用
15 数据缓存策略:让我们的应用 “快如闪电” 接口数据缓存、内存缓存、本地缓存方案,减少重复请求提升响应速度
16 Pinia 高效指南:状态管理的最佳实践与性能陷阱 Pinia 核心用法、模块化拆分、异步操作、常见性能问题与规避
17 Vue Router 进阶:路由懒加载、导航守卫与元信息的高效运用 路由懒加载、权限守卫、路由元信息、导航解析流程优化

第四部分:Vue3 应用运行时性能优化实战

序号 标题 文章简介
18 虚拟列表完全指南:从原理到实战,轻松渲染 10 万条数据 虚拟列表核心原理、固定 / 动态高度实现,解决大数据渲染卡顿
19 v-once 和 v-memo 完全指南:告别不必要的渲染,让应用飞起来 用 v-once/v-memo 减少冗余更新,精准控制组件渲染粒度
20 shallowRef 与 shallowReactive:浅层响应式的妙用 浅层响应式 API 用法、性能优势,处理海量数据时降低响应式开销
21 事件监听器销毁完全指南:如何避免内存泄漏 组件销毁时正确清理监听、定时器、DOM 事件,杜绝内存泄漏
22 函数式组件 vs 有状态组件:何时使用更高效? 对比两类组件性能、适用场景,给出 Vue3 中合理选型建议
23 异步组件与 Suspense:如何优雅地处理加载状态并优化首屏加载 计划讲解异步组件拆分、Suspense 加载状态管理,优化首屏体验

第五部分:Vite 构建优化与工程化配置

序号 标题 文章简介
24 Vite 核心原理:ESM 带来的开发时“瞬移”体验 解析 Vite 基于 ESM 的开发服务器、依赖预构建、热更新原理
25 开发环境优化完全指南:告别等待,让开发如丝般顺滑 Vite 启动、热更新优化,依赖缓存、代理配置提升开发效率
26 生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南 代码分割、资源压缩、压缩算法配置,最大化减小包体积
27 网络请求在 Vite 层的代理与 Mock:告别跨域和后端依赖 Vite 代理解决跨域,Mock 接口模拟,脱离后端独立开发
28 ESLint + Prettier + Husky + lint-staged:建立自动化的高效前端工作流 搭建代码规范、格式化、提交校验工作流,统一团队代码质量

第六部分:图片优化专题系列

序号 标题 文章简介
29 Vite 构建层面的图片优化:从压缩到转换 利用 Vite 插件实现图片自动压缩、WebP/AVIF 格式自动转换、按需加载,在构建阶段减小图片资源体积
30 Vue3 组件中的图片懒加载与渐进式加载 实现组件级图片懒加载,结合占位图、模糊渐进式加载,优化图片加载体验,减少首屏资源请求
31 响应式图片的工程化实践:srcset 与 picture 讲解 srcset 与 picture 标签的使用技巧,实现多分辨率、多格式图片的自适应加载,适配不同设备与网络环境
32 CDN 图片服务与动态参数优化 详解 CDN 图片服务的动态参数配置,包括裁剪、缩放、压缩、格式转换,实现图片的精细化、按需加载

第七部分:测试与质量保障

序号 标题 文章简介
33 Vue3 单元测试:用 Vitest 为关键组件和组合式函数编写测试 讲解 Vitest 的配置与使用,为 Vue3 组件、组合式函数编写单元测试,实现核心逻辑的自动化校验,提升代码质量
34 组件测试策略:测试 Props、事件和插槽 给出组件的完整测试策略,包括 Props 传值、事件触发、插槽渲染的测试用例编写,覆盖组件的核心交互场景

第八部分:实战篇 - 解决真实场景的疑难杂症

序号 标题 文章简介
35 案例分析:一个复杂表单的响应式性能优化 以真实复杂表单为例,分析响应式卡顿的核心原因,给出表单拆分、响应式优化、渲染优化的实战方案
36 案例分析:大屏可视化项目的卡顿排查与解决 针对大屏可视化项目的渲染瓶颈,讲解性能排查方法,给出画布优化、数据分片、渲染节流的实战解决方案
37 案例分析:从“慢”到“快”,一个后台管理页面的优化全记录 完整复盘后台管理页面的优化流程,包括接口、渲染、资源、交互全维度优化,实现页面加载与操作的极致流畅
38 终局之战:全链路性能体检与监控 讲解前端性能指标的监控方法,搭建全链路性能体检体系,实现性能问题的实时监控、告警与定位,保障应用性能稳定性

栗子前端技术周刊第 122 期 - TypeScript 6.0、pnpm 11 Beta、Storybook 10.3.0...

2026年3月30日 09:27

🌰栗子前端技术周刊第 122 期 (2026.03.23 - 2026.03.29):浏览前端一周最新消息,学习国内外优秀文章,让我们保持对前端的好奇心。

📰 技术资讯

  1. TypeScript 6.0:TypeScript 6.0 正式发布,经过六个多月的开发,TypeScript 6.0 旨在衔接其自托管编译器与(即将就绪的)基于 Go 语言构建的 TypeScript 7.0 原生编译器,本次版本带来了诸多新特性(如 Temporal 的改进、RegExp.escape 等),但最重要的是那些帮助你为 7.0 做好准备的变化。

  2. pnpm 11 Beta:pnpm 11 测试版发布,内容包括全新 SQLite 驱动存储、配置体系重构,且默认启用更严格的构建安全策略等。

  3. Storybook 10.3.0:Storybook 10.3.0 发布,现已支持 Vite 8、Next.js 16.2 与 ESLint 10,同时上线了面向 React 开发的 MCP 服务器预览版。

📒 技术文章

  1. The Three Pillars of JavaScript Bloat:JavaScript 臃肿化的三大根源 - 作者剖析了 JavaScript 代码臃肿的三大核心症结,还解读了 npm 依赖树为何充斥大量冗余包。

  2. 手把手搭一套前端监控采集 SDK:完整的前端监控平台通常分成三块:采集与上报、整理与存储、展示与分析。本文只讲第一块,从 0 搭一个可运行的埋点 SDK,并把指标采集方式对齐到当前浏览器与 Core Web Vitals 的常见做法。

  3. 不懂模块化就别谈前端工程化:前端模块化是将大型代码拆分成独立小块的开发方式,每个模块专注单一功能,提高了代码的可维护性和复用性。掌握模块化是前端工程化的基础,为后续使用 Webpack 等构建工具奠定了重要基础。

🔧 开发工具

  1. Sugar High 1.0:轻量 JSX 语法高亮器,无需依赖 React,可在任意场景为 JSX 代码片段做语法高亮。还支持通过 CSS 自定义主题。
image-20260329154905445
  1. vue-audio-visual 3.1:基于 Web Audio API 打造的 Vue 3 HTML5 音频可视化组件库。
image-20260329155045733
  1. Stroke:在页面画布上手绘内容(比如签名、手写笔迹),点击按钮,就能生成一段动画动效代码,直接嵌入组件里,就能复现你画出来的动态效果。
image-20260329160330324

🚀🚀🚀 以上资讯文章选自常见周刊,如 JavaScript Weekly 等,周刊内容也会不断优化改进,希望你们能够喜欢。

💖 欢迎关注微信公众号:栗子前端

🚀 拒绝“CSS 命名困难症”!手把手带你用 Tailwind CSS 搓一个“高颜值”登录页

作者 AI的主人
2026年3月30日 09:24

🚀 拒绝“CSS 命名困难症”!手把手带你用 Tailwind CSS 搓一个“高颜值”登录页

前言:你是否也经历过这样的绝望

深夜两点,你还在为登录页的一个按钮居中而抓狂。 你给 div 起了个名字叫 .wrapper,后来发现不够用,改成了 .main-wrapper,最后变成了 .super-duper-main-wrapper-final-v2。 你想改个颜色,结果在全局 CSS 文件里搜到了 50 个 .text-primary,你不敢动,生怕把隔壁老王开发的页面搞崩了。

朋友,停下来! 今天,我要向你安利(或者说是按头推荐)前端界的“乐高大师”—— Tailwind CSS。我们将结合 React 和 Vite,用一种极其优雅(且不用想类名)的方式,从零构建一个现代化的登录页面。

准备好了吗?系好安全带,我们要起飞了!


🛠️ 第一步:工欲善其事,必先配环境

咱们不整那些虚的,直接上目前最爽的“三剑客”组合:Vite + React + Tailwind CSS

为什么选 Vite?因为它快!快到让你怀疑人生,就像你那个总是秒回的暧昧对象(如果有的话)。

初始化项目

pnpm create vite@latest my-login-app --template react
cd my-login-app
pnpm install
pnpm install -D tailwindcss postcss autoprefixer
pnpm install lucide-react # 漂亮的图标库,别再用丑丑的字符了

关键配置(敲黑板): 在 vite.config.js 里,别忘了加上 Tailwind 的插件,否则你的样式就像没穿裤子一样——虽然能跑,但没法看。

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' // 这一行是灵魂!

export default defineConfig({
  plugins: [react(), tailwindcss()],
})

🧠 第二步:数据驱动 UI,拒绝“面条代码”

很多新手写登录页,HTML 和逻辑混在一起,像一碗煮烂的面条。我们要用 React 的受控组件思想,把数据拿捏得死死的。

看这段核心逻辑,这才是现代前端该有的样子:

import { useState } from 'react';
import { Lock, Mail, EyeOff, Eye } from 'lucide-react';

export default function App() {
  // 1. 状态管理:把表单看成一个数据库
  const [formData, setFormData] = useState({
    email: '',
    password: '',
    rememberMe: false
  })
  
  // 2. 抽象的事件处理:一个函数统治所有输入框
  // 别问为什么不用两个函数,问就是“代码洁癖”
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData((prev) => ({
      ...prev, 
      [name]: type === "checkbox" ? checked : value 
    }));
  }

  // 3. 密码显隐控制:这是登录页的标配
  const [showPassword, setShowPassword] = useState(false);
  
  // ... 提交逻辑省略 ...

亮点解析

  • 受控组件:输入框的值完全由 formData 说了算。
  • 抽象逻辑handleChange 就像是一个智能路由器,不管是邮箱还是密码,统统拦截处理。这种写法,老板看了都得给你加鸡腿。

🎨 第三步:Tailwind CSS 实战——像玩乐高一样写样式

好了,逻辑通了,现在来点视觉冲击。我们要实现一个居中、带阴影、圆角、响应式的完美卡片。

1. 布局:让元素“乖乖听话”

想要一个元素在屏幕正中间?以前你可能要写 flex, justify-center, items-center,还要给 bodyheight: 100vh

在 Tailwind 里,只需要一行: min-h-screen bg-slate-50 flex items-center justify-center p-4

  • min-h-screen:相当于 min-height: 100vh,保证占满全屏。
  • bg-slate-50:给个淡淡的背景色,别总是惨白惨白的。
  • p-4:移动端优先,给点内边距,别让内容贴着屏幕边缘。

2. 卡片:打造“高级感”

这是今天的主角,我们的登录卡片:

<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border border-slate-100 p-8 md:p-10">

深度解析

  • max-w-md:限制最大宽度。不管你在 27 寸显示器还是 iPad 上,它都保持一个优雅的宽度,不会拉得像拉面一样长。
  • shadow-xl shadow-slate-200/60这是点睛之笔! 默认的阴影太黑太生硬,我们用 shadow-slate-200/60 给阴影加个颜色滤镜和透明度,瞬间拥有“弥散光感”,高级感拉满!
  • rounded-3xl:大圆角,现在的流行趋势就是“圆润”。
  • md:p-10响应式魔法! 手机上内边距是 p-8,到了中等屏幕(md)自动变成 p-10。这就是 Mobile First 的魅力。

3. 间距:治愈“对齐强迫症”

表单元素之间空多少?别瞎猜 margin-bottom: 15px 了。 用 space-y-6

<form className='space-y-6'>

这个类会自动给所有子元素(除了第一个)加上 margin-top。不管你有几个输入框,间距永远整齐划一。

4. 输入框:细节决定成败

我们要做一个带图标、带聚焦效果的输入框。

<div className="relative group">
  {/* 图标 */}
  <div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none text-slate-400 group-focus-within:text-indigo-600 transition-colors">
    <Mail size={18} />
  </div>
  
  {/* 输入框 */}
  <input 
    type="email" 
    className="block w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl ... focus:ring-2 focus:ring-indigo-600/20 ..."
  />
</div>

这里有几个骚操作

  • groupgroup-focus-within:当输入框(子元素)获得焦点时,外面的 div(父元素)也能感知到!于是图标颜色瞬间变蓝。这种交互细节,用户体验直接提升一个档次。
  • pl-11:左边留空给图标,别让文字盖在图标上了。
  • focus:ring-indigo-600/20:聚焦时不仅边框变色,还有一圈淡淡的“光晕”(Ring),这比默认的蓝色轮廓线好看一万倍。

🌟 第四步:状态驱动——Loading 与 密码显隐

界面不是死的,它是活的!

密码显隐: 利用我们之前写的 showPassword 状态,动态切换 type 属性。 type={showPassword ? "text" : "password"} 配合 Lucide 图标,一只眼睛睁开(看),一只眼睛闭上(藏),丝滑切换。

Loading 状态: 虽然代码里没贴全,但想象一下,点击登录后,按钮变成“登录中...”,并且出现一个转圈圈的动画。 这就是数据驱动 UIisLoadingtrue 时,按钮变灰,禁止点击,显示 Spinner。这才是专业的交互,而不是让用户傻乎乎地点半天没反应。


📌 总结:为什么 Tailwind CSS 是“真香”定律?

写完这个页面,你可能会有以下感觉:

  • 不用想类名:再也不用纠结是用 .login-btn 还是 .submit-button 了,直接用 bg-indigo-600
  • 修改极快:想改间距?把 p-4 改成 p-6 只要一秒钟。
  • 文件极小:Tailwind 会自动扫描你的 HTML,没用到的样式直接剔除(Tree Shaking),打包出来可能只有几 KB。
  • 响应式顺手:加个 md: 前缀就搞定大屏适配,简直不要太爽。

最后送大家一句话: CSS 不是洪水猛兽,Tailwind 就是你的屠龙宝刀。别再用 !important 覆盖样式了,那是弱者的行为。

快去用 Tailwind CSS 搓一个属于你的高颜值页面吧!如果老板问你为什么写得这么快,就把这篇文章甩给他看。

写这需求快崩溃了,幸好我会装饰器模式

作者 河马老师
2026年3月30日 09:22

目的

装饰器模式(Decorator Pattern) 的目的非常简单,那就是:在不修改原有代码的情况下增加逻辑。 这句话听起来可能有些矛盾,既然都要增加逻辑了,怎么可能不去修改原有的代码?但 SOLID (向对象设计5大重要原则)的开放封闭原则就是在试图解决这个问题,其内容是不去改动已经写好的核心逻辑,但又能够扩充新逻辑,也就是对扩展开放,对修改关闭。

举个例子,假如产品的需求是实现一个专门在浏览器的控制台中输出文本的功能,你可能会这样做:

class Printer {  
  print(text) {  
    console.log(text);  
  }  
}  
  
const printer = new Printer();  
printer.print('something'); // something

在你满意的看着自己的成果时,产品过来说了一句:“我觉得颜色不够突出,还是把它改成黄色的吧!”

小菜一碟!你自信的打开百度一通操作之后,把代码改成了下面这样子:

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;');  
  }  
}

image.png

但产品看了看又说:“这个字体有点太小了,再大一点,最好是高端大气上档次那种。

”好吧。。。“你强行控制着自己拿刀的冲动,一边琢磨多大的字体才是高端大气上档次,一边修改 print 的代码:

image.png

class Printer {  
  print(text) {  
    console.log(`%c${text}`,'color: yellow;font-size: 36px;');  
  }  
}

image.png

这次改完你之后你心中已经满是 mmp 了,而且偷偷给产品贴了个标签:

image.png

你无法保证这次是最后的修改,而且也可能会不只一个产品来对你指手划脚。你呆呆的看着显示器,直到电脑进入休眠模式,屏幕中映出你那张苦大仇深的脸,想着不断变得乱七八糟的 print 方法,不知道该怎么去应付那些永无休止的需求。。。

image.png

在上面的例子中,最开始的 Printer 按照需求写出它应该要有的逻辑,那就是在控制台中输出一些文本。换句话说,当写完“在控制台中输出一些文本”这段逻辑后,就能将 Printer 结束了,因为它就是 Printer 的全部逻辑了。那在这个情况下该如何改变字体或是颜色的逻辑呢?

这时你该需要装饰器模式了。

Decorator Pattern(装饰器模式)

首先修改原来的 Printer,使它可以支持扩充样式:

class Printer {  
  print(text = '', style = '') {  
    console.log(`%c${text}`, style);  
  }  
}

之后分别创建改变字体和颜色的装饰器:

const yellowStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  }  
});  
  
const boldStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-weight: bold;`);  
  }  
});  
  
const bigSizeStyle = (printer) => ({  
  ...printer,  
  print(text = '', style = '') => {  
    printer.print(text, `${style}font-size: 36px;`);  
  }  
});

代码中的 yellowStyleboldStyle 和 bigSizeStyle 分别是给 print 方法的装饰器,它们都会接收 printer,并以 printer 为基础复制出一个一样的对象出来并返回,而返回的 printer 与原来的区别是,各自 Decorator 都会为 printer 的 print 方法加上各自装饰的逻辑(例如改变字体、颜色或字号)后再调用 printer 的 print

使用方式如下:

image.png

只要把所有装饰的逻辑抽出来,就能够自由的搭配什么时候要输出什么样式,加入要再增加一个斜体样式,也只需要再新增一个装饰器就行了,不需要改动原来的 print 逻辑。

image.png

不过要注意的是上面的代码只是简单的把 Object 用解构复制,如果在 prototype 上存在方法就有可能会出错,所以要深拷贝一个新对象的话,还需要另外编写逻辑:

const copyObj = (originObj) => {  
  const originPrototype = Object.getPrototypeOf(originObj);  
  let newObj = Object.create(originPrototype);  
     
  const originObjOwnProperties = Object.getOwnPropertyNames(originObj);  
  originObjOwnProperties.forEach((property) => {  
    const prototypeDesc = Object.getOwnPropertyDescriptor(originObj, property);  
     Object.defineProperty(newObj, property, prototypeDesc);  
  });  
    
  return newObj;  
}

然后装饰器内改使上面代码中的 copyObj,就能正确复制相同的对象了:

const yellowStyle = (printer) => {  
  const decorator = copyObj(printer);  
  
  decorator.print = (text = '', style = '') => {  
    printer.print(text, `${style}color: yellow;`);  
  };  
  
  return decorator;  
};

其他案例

因为我们用的语言是 JavaScript,所以没有用到类,只是简单的装饰某个方法,比如下面这个用来发布文章的 publishArticle

const publishArticle = () => {  
  console.log('发布文章');  
};

如果你想要再发布文章之后在 微博或QQ空间之类的平台上发个动态,那又该怎么处理呢?是像下面的代码这样吗?

const publishArticle = () => {  
  console.log('发布文章');  
  
  console.log('发 微博 动态');  
  console.log('发 QQ空间 动态');  
};

这样显然不好!publishArticle 应该只需要发布文章的逻辑就够了!而且如果之后第三方服务平台越来越多,那 publishArticle 就会陷入一直加逻辑一直爽的情况,在明白了装饰器模式后就不能再这样做了!

所以把这个需求套上装饰器:

const publishArticle = () => {  
  console.log('发布文章');  
};  
  
const publishWeibo = (publish) => (...args) => {  
  publish(args);  
  console.log('发 微博 动态');  
};  
  
const publishQzone = (publish) => (...args) => {  
  publish(args);  
  console.log('发 QQ空间 动态');  
};  
  
  
const publishArticleAndWeiboAndQzone = publishWeibo(publishQzone(publishArticle));

前面 Printer 的例子是复制一个对象并返回,但如果是方法就不用复制了,只要确保每个装饰器都会返回一个新方法,然后会去执行被装饰的方法就行了。

image.png

总结

装饰器模式是一种非常有用的设计模式,在项目中也会经常用到,当需求变动时,觉得某个逻辑很多余,那么直接不装饰它就行了,也不需要去修改实现逻辑的代码。每一个装饰器都做他自己的事情,与其他装饰器互不影响。

前端预检请求是什么?

作者 凉城a
2026年3月30日 09:09

前言

本文谈到的前端预检请求其实就是解决跨域方案其中之一的安全机制,可以理解为你想进一个小区,门口有一个保安“拦截器”,通过了保安“拦截器”检查,就可以进入了。

关于跨域(端口、协议、域名),想必大家都不陌生吧,回想下跨域的相关知识以及解决方案,就知道前端预检请求的来源了。

知其然知其所以然

一、浏览器的‌同源策略

跨域问题主要源于浏览器的‌同源策略(Same-Origin Policy) ‌。该策略是浏览器最核心的安全机制之一,用于防止不同源之间的恶意行为。

同源的定义‌:
当一个请求的 URL 的协议、域名、端口三者中任意一个与当前页面的 URL 不同时,就称为跨域请求。

例如:

  • 协议不同:http://example.com 和 https://example.com
  • 域名不同:http://www.example.com 和 http://www.other.com
  • 端口不同:http://example.com:8080 和 http://example.com:8081

即使两个域名指向同一个 IP 地址,也属于跨域。

由于浏览器出于安全考虑,同源策略限制了以下行为:

  • 无法读取非同源网页的 Cookie、LocalStorage 和 IndexedDB;
  • 无法访问非同源网页的 DOM;
  • 无法向非同源地址发送 AJAX 请求。

二、解决跨域的方法

1. CORS(跨域资源共享)

CORS 是目前最常用且推荐的跨域解决方案。它通过在服务器端设置响应头来允许特定源访问资源。

  • 服务器通过设置 Access-Control-Allow-Origin 响应头来指定允许访问的源。
  • 可以设置为具体域名或 *(表示允许所有源)。
  • 支持所有类型的 HTTP 请求,功能完善。 ‌

2. JSONP(JSON with Padding)

JSONP 是一种利用 <script> 标签不受同源策略限制的特性实现跨域请求的方式。

  • 仅支持 GET 请求;
  • 存在安全风险,容易受到 XSS 攻击;
  • 目前已被 CORS 取代。

3. 代理服务器(正向/反向代理)

通过在本地搭建一个代理服务器,前端请求先发送到代理服务器,再由代理服务器转发到目标服务器。

  • 在开发环境中常用前端脚手架配置代理;
  • 生产环境则可以使用 Nginx 等反向代理工具。

4. WebSocket

WebSocket 协议不遵循同源策略,适用于需要实时通信的场景。

5. postMessage API

用于不同窗口或 iframe 之间传递消息,常用于跨域通信。

6. document.domain + iframe

适用于主域名相同但子域名不同的情况。

7. window.name + iframe

通过 iframe 的 window.name 属性实现跨域数据传递。

前端预检请求是什么?

前端预检请求(Preflight Request)是浏览器在发起某些跨域请求前,自动发送的一个 ‌OPTIONS‌ 请求,用于确认服务器是否允许实际的跨域请求。这种机制是为了保证安全性,防止未经允许的跨域请求对服务器数据造成影响。

CORS(跨域资源共享)机制在特定条件下会触发预检请求(Preflight Request) ‌。

注意‌:如果请求满足“简单请求”的所有条件(如使用 GETPOSTHEAD 方法,且 Content-Type 为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain),则不会触发预检请求。

何时会触发预检请求?

当请求不满足“简单请求”的条件时,浏览器就会自动触发一个预检请求。简单请求包括以下几种情况:

  • 使用 ‌GET、HEAD 或 POST‌ 方法;
  • 请求头仅包含以下字段:AcceptAccept-LanguageContent-LanguageContent-Type(且值为 text/plainmultipart/form-data 或 application/x-www-form-urlencoded)。

如果请求中包含以下任意一种情况,则会被视为“非简单请求”,从而触发预检请求:

  1. 使用了非简单 HTTP 方法,如 ‌PUT、DELETE、PATCH‌ 等;
  2. 请求头中包含了自定义字段,例如 AuthorizationX-Custom-Header 等;
  3. Content-Type 设置为 application/jsonapplication/xml 等非简单类型;
  4. 请求中携带了凭证(如 Cookie),需设置 withCredentials = true

预检请求的内容

预检请求是一个 ‌OPTIONS‌ 方法的请求,它会携带以下关键请求头:

  • Access-Control-Request-Method:表示实际请求将使用的 HTTP 方法;
  • Access-Control-Request-Headers:列出实际请求中使用的自定义头部。

服务器如何响应预检请求?

服务器需要返回一系列 CORS 响应头来表明其是否允许该跨域请求:

  • Access-Control-Allow-Origin:指定允许访问的源;
  • Access-Control-Allow-Methods:列出允许的 HTTP 方法;
  • Access-Control-Allow-Headers:声明允许的请求头部;
  • Access-Control-Allow-Credentials:是否允许携带凭证(如 Cookie);
  • Access-Control-Max-Age:指定预检请求结果的缓存时间,减少重复预检。

为什么需要预检请求?

预检请求本质上是一种安全机制,确保服务器明确知道并同意来自某个源的请求。这可以避免一些潜在的安全风险,比如在未授权的情况下向服务器发送敏感操作。

如何优化预检请求?

预检请求是现代浏览器为保障跨域请求安全而设计的一种机制,虽然会带来额外的网络开销,但在必要时是不可或缺的。

站在开发者角度,性能优化还是少不了的。全面认识了预检请求,优化方案减少必要自己也就出来了。为什么平时开发项目大部分是简单请求GET、HEAD 或 POST‌?这个问题答案也自己出来了吧。

总结下为了减少不必要的预检请求,可以采取以下策略:

  1. 尽量使用简单请求‌:避免使用非标准方法或自定义头部;
  2. ‌**合理设置 Access-Control-Max-Age**‌:通过设置较长的缓存时间,减少重复的 OPTIONS 请求;
  3. 使用代理服务器‌:将跨域请求转发到同源接口,绕过浏览器的 CORS 检查。

本文思维导图

前言 (1).png

写在最后

我是凉城a,一个前端,热爱技术也热爱生活。

与你相逢,我很开心。

如果你想了解更多,请点这里,期待你的小⭐⭐

  • 文中如有错误,欢迎在评论区指正,如果这篇文章帮到了你,欢迎点赞和关注😊
  • 本文首发于掘金,未经许可禁止转载💌

组件契约文档的标准结构(可复制模板)

作者 LeonGao
2026年3月30日 09:08

这节给你一份“组件契约模板”。它的写法重点不是把信息堆满,而是把争议点提前固定成条款。你们可以把它放进组件库站点、README、或直接放进设计稿旁边的说明区;只要团队默认“以契约为准”,沟通成本会明显下降。

1)基本信息(让人快速定位)

  • 组件名 / 英文标识:例如 Button / AppButton
  • 组件目标(一句话) :它解决什么问题,不解决什么问题
  • 适用场景 / 不适用场景:例如“用于提交/确认动作;不用于页面跳转(应使用 Link)”
  • 依赖与前置:是否依赖主题系统、国际化、表单框架、路由等

写法提示:目标要写“边界”,别写口号。边界越清晰,越少被滥用。(Krug, 2014)

2)视觉与布局约束(设计最关心,但要写成可验收条款)

  • 尺寸与密度:size 列表(S/M/L)、高度、左右 padding 的区间
  • 层级与变体:primary/secondary/text/danger 等,哪些必须提供
  • 图标规则:leading/trailing icon 是否允许、大小、间距
  • 对齐规则:文本对齐、按钮组间距、换行策略
  • 主题与暗色模式:是否支持、失败时的降级策略

条款化例子(比“看起来差不多”更可判定):

  • “同一按钮在 S/M/L 三个尺寸下,高度固定为 28/32/36px(或按你们系统),文本基线居中。”
  • “danger 变体在 hover/focus/disabled 三态下都要有视觉区分,否则视为不合格。”

3)API 契约(研发最关心:输入、输出、默认值)

建议用表格写清楚,每一项都要回答:类型、是否必填、默认值、是否受控、与其他属性的联动

  • Props/Slots

    • value / modelValue:受控还是非受控?
    • defaultValue:只在首次生效还是每次都生效?
    • disabled / readonly:差异是什么?
  • Events/Callbacks

    • 触发时机:onChange 在输入中触发还是失焦触发?
    • 参数形状:回传值、错误对象、原生 event 是否暴露
  • Methods/Ref(如果有)

    • 是否提供 focus() / scrollIntoView()
  • A11y Props(可访问性)

    • 是否透传 aria-*,有哪些必填项(如 aria-label

写法提示:API 要写“互斥关系”和“优先级”。例如 disabled=true 时,onClick 是否还会触发?如果会触发,那测试与埋点都要跟着改。

4)状态机(测试和联调最需要:什么时候是什么状态)

这是减少扯皮的关键段落。用“状态 + 触发条件 + UI 表现 + 交互限制”写清。

建议最少覆盖:

  • default:默认态
  • hover / active / focus:交互态(含键盘 focus)
  • loading:加载态(是否可点击、是否展示 spinner、是否保留宽度防抖动)
  • disabled:禁用态(是否可聚焦、tooltip 是否可用)
  • error / warning / success:反馈态(尤其对表单类组件)
  • empty:空态(列表、下拉、表格常见)

推荐写成表格或小型状态图。状态越多,越要用“表格化表达”降低理解负担。(Miller, 1956)

5)边界条件与异常处理(最容易引发争论的一块)

把“极端情况怎么做”写成条款,避免每次都临时决定。常见边界包括:

  • 超长文本:截断/换行/tooltip 规则
  • 无数据 / null:展示什么文案,是否允许自定义
  • 慢请求:loading 延迟阈值(比如 >300ms 才显示 spinner,防闪烁)
  • 并发更新:最后一次写入优先还是队列处理
  • 错误呈现:红字、红框、toast、还是静默;是否可重试
  • 国际化:文字长度变化对布局的影响,是否允许两行

这段要写得“像判例”。你写得越具体,回归越省力。

6)一致性与可替换性(面向设计系统/组件库治理)

  • 与设计 Token 的关系:哪些颜色/圆角/间距必须来自 Token
  • 可替换约束:未来升级版本时,哪些 API 不能动(兼容承诺)
  • 弃用策略(Deprecation) :老属性何时废弃,如何提示迁移

这块能让组件库变成“可演进的产品”,而不是一次性工程。(Gamma et al., 1994)

7)验收标准(Definition of Done)

把“验收”从口头变成清单:

  • 功能验收:列出必须通过的用例
  • 视觉验收:列出必须对齐的点(像素级还是容差范围)
  • 可访问性验收:键盘可操作、读屏标签、对比度要求
  • 性能验收:渲染耗时、列表虚拟化阈值(如适用)
  • 兼容性验收:浏览器/端侧范围

写法提示:验收标准要能被测试自动化部分覆盖,剩下才是人工 spot check。

8)测试建议与用例(让测试能直接抄)

  • 最小回归集(必须自动化的 5–10 条)
  • 关键交互用例(键盘、焦点、复制粘贴、拖拽如有)
  • 异常用例(超时、接口报错、空数据、权限不足)
  • 可视化回归建议(截图点位、状态覆盖)

这里建议引用“测试金字塔”的思想:单测覆盖逻辑、集成测覆盖组件行为、少量 e2e 覆盖关键链路。(Cohn, 2009)


一句话总结(给你放在模板顶部)

组件契约文档 = 视觉条款 + API 条款 + 状态机 + 边界判例 + 验收清单。

❌
❌