普通视图

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

JavaScript设计模式(六):职责链模式实现与应用

2026年3月31日 11:42

在日常开发中,我们经常会遇到这样一类场景:一个请求或者动作,不是某一个模块立刻处理完,而是要先经过多道检查

比如用户访问一个后台页面时,可能要先检查:

  • 是否已登录。
  • token 是否过期。
  • 是否有访问权限。
  • 是否满足某些业务条件。

如果这些逻辑全都堆在一个函数里,很快就会变成一长串 if-else,后面无论新增规则还是调整顺序,都会越来越难维护。

这个场景,就非常适合职责链模式

1、职责链模式定义

职责链模式的定义是:使多个对象都有机会处理请求,从而避免请求的发送者和接收者之间的耦合关系,并将这些对象连成一条链,沿着这条链传递该请求,直到有一个对象处理它为止。

简单来说就是:

  • 一个请求会沿着一条链依次往后传。
  • 链上的每个节点只负责自己那一段逻辑。
  • 当前节点处理不了,或者不需要拦截,就交给下一个节点。

它的重点不是“谁一定要处理”,而是“让请求在多个处理者之间有序流动”。

2、核心思想

  1. 发送者和处理者解耦:请求发起方不需要关心到底是谁处理,只需要把请求交出去。
  2. 每个节点只做一件事:链上的每个处理者只关心自己的职责,比如只校验登录态,或者只校验权限。
  3. 可动态组合和扩展:职责链的顺序可以调整,新增节点也不需要大改原有逻辑。

3、例子:页面访问前的校验链

在实际项目里,用户访问一个管理后台页面时,常常不是直接渲染页面,而是要经过好几层前置处理。

比如访问 /admin/order 时,可能要先做这些事情:

  • 检查用户是否登录。
  • 如果 token 过期,则自动刷新。
  • 检查当前用户是否有页面权限。
  • 一切正常后,才允许进入页面。

3.1 不用职责链模式(所有逻辑堆在一个函数里)

如果我们不用职责链模式,代码很容易写成这样:

async function handleRouteEnter(to) {
  if (!isLogin()) {
    redirect('/login');
    return;
  }

  if (isTokenExpired()) {
    try {
      await refreshToken();
    } catch (error) {
      redirect('/login');
      return;
    }
  }

  if (!hasPermission(to.meta.permission)) {
    redirect('/403');
    return;
  }

  renderPage(to);
}

这样写虽然能跑,但问题也很明显:

  1. 逻辑越来越重:所有前置处理都塞在一个函数里,职责不清晰。
  2. 难扩展:如果后面还要加“黑名单校验”“灰度校验”“埋点”“实验分流”,这个函数会越来越长。
  3. 顺序耦合严重:每一步逻辑都写死在一起,后续调整顺序或者复用部分逻辑都比较麻烦。

3.2 使用职责链模式

我们可以把每一步处理拆成独立的处理节点,让请求沿着链条依次往后走。

class Handler {
  setNext(handler) {
    // 保存下一个处理节点
    this.next = handler;
    // 返回下一个节点,方便链式调用
    return handler;
  }

  async handle(context) {
    if (this.next) {
      // 当前节点不处理时,继续往后传
      return this.next.handle(context);
    }
  }
}

class LoginHandler extends Handler {
  async handle(context) {
    // 未登录则直接拦截,终止后续链条
    if (!isLogin()) {
      redirect('/login');
      return;
    }

    // 已登录,交给下一个节点
    return super.handle(context);
  }
}

class RefreshTokenHandler extends Handler {
  async handle(context) {
    // token 过期时,先尝试刷新
    if (isTokenExpired()) {
      try {
        await refreshToken();
      } catch (error) {
        // 刷新失败,同样终止链条
        redirect('/login');
        return;
      }
    }

    // token 正常后继续往后传
    return super.handle(context);
  }
}

class PermissionHandler extends Handler {
  async handle(context) {
    // 没有页面权限时直接拦截
    if (!hasPermission(context.to.meta.permission)) {
      redirect('/403');
      return;
    }

    // 有权限则放行
    return super.handle(context);
  }
}

class RenderHandler extends Handler {
  async handle(context) {
    // 前面的校验都通过后,才真正渲染页面
    renderPage(context.to);
  }
}

const loginHandler = new LoginHandler();
const refreshTokenHandler = new RefreshTokenHandler();
const permissionHandler = new PermissionHandler();
const renderHandler = new RenderHandler();

loginHandler
  .setNext(refreshTokenHandler)
  .setNext(permissionHandler)
  .setNext(renderHandler);

// 从链头开始处理请求
loginHandler.handle({
  to: {
    path: '/admin/order',
    meta: {
      permission: 'order:read'
    }
  }
});

这样改造之后有几个明显的好处:

  • LoginHandler 只负责登录校验。
  • RefreshTokenHandler 只负责处理 token 刷新。
  • PermissionHandler 只负责权限判断。
  • 真正渲染页面的逻辑,也被单独拆到了最后一个节点。

这就是职责链模式最典型的思想:把一个大流程拆成多个独立节点,请求沿着链条依次传递,谁该处理谁处理,处理不了就往后传。

3.3 职责链里最关键的是“传递”

职责链模式有一个特别关键的点,就是:当前节点处理完之后,要不要继续往后传

比如:

  • 用户未登录,那么 LoginHandler 就直接拦截,不再往后走。
  • 用户已登录,那么请求继续传给下一个节点。
  • 用户有权限,则继续往后传。
  • 用户没有权限,则在当前节点终止。

也就是说,职责链里的每个节点通常都拥有两种能力:

  1. 处理请求并终止链条
  2. 放行请求并交给下一个节点

这也是它和普通“函数拆分”不一样的地方,职责链不仅仅是在拆模块,更是在定义一套清晰的流转关系。

4、职责链模式和中间件的关系

很多开发同学第一次学职责链模式时,都会觉得它和 KoaExpressVue Router 里的中间件机制很像,这种感觉其实非常对。

比如我们在很多框架里都会看到 next()

function checkLogin(ctx, next) {
  if (!isLogin()) {
    redirect('/login');
    return;
  }

  // 放行,进入下一个中间件
  next();
}

function checkPermission(ctx, next) {
  if (!hasPermission(ctx.permission)) {
    redirect('/403');
    return;
  }

  // 当前校验通过,继续往后走
  next();
}

这里的 next(),本质上就是“把请求交给下一个处理节点”。

从设计思想上看,中间件机制和职责链模式是非常接近的:

  • 每个中间件只处理自己关心的逻辑。
  • 当前中间件处理完后,可以决定是否调用 next()
  • 整个请求会按照顺序在多个处理节点之间流动。

所以我们完全可以说:中间件机制,很多时候就是职责链模式在框架层面的一种体现。

5、职责链模式的优缺点

5.1 优点:

  • 解耦性强:请求发送者不需要知道到底由哪个节点处理。
  • 职责清晰:每个节点只负责一类逻辑,更符合单一职责原则。
  • 扩展方便:新增一个处理节点,通常只需要插入到链条中即可。
  • 顺序灵活:可以根据业务需要调整链条的先后顺序。

5.2 缺点:

  • 请求链过长时不容易排查问题:如果一个请求经过很多节点,调试时可能不容易第一时间看出卡在哪一环。
  • 可能存在性能损耗:链条过长时,每次请求都要经过多个节点,会增加一些额外开销。
  • 节点顺序敏感:比如先校验权限还是先刷新 token,顺序不同,结果可能完全不同。

6、职责链模式的应用

职责链模式在日常业务开发里都非常常见,比如:

  1. 路由守卫:登录校验、权限校验、页面跳转控制。
  2. 表单校验:必填校验、格式校验、长度校验、业务规则校验。
  3. 请求拦截器:统一加 token、刷新凭证、错误处理。
  4. 审批流系统:一级审批、二级审批、三级审批,逐级处理。
  5. 中间件机制:KoaExpressRedux middleware 等。

7、职责链模式和策略模式的区别

职责链模式和策略模式都很常见,而且都带一点“拆分逻辑”的味道,所以也很容易混淆。

但它们的核心区别很明显:

  • 策略模式:更强调“从多个算法里选一个合适的来执行”。
  • 职责链模式:更强调“让请求沿着多个处理节点依次传递”。

你可以简单理解为:

  • 策略模式更像是在说:“这次我选哪一种方案?”
  • 职责链模式更像是在说:“这次请求要经过哪些关卡?”

举个很直观的例子:

  • 支付时选择 支付宝 / 微信 / 银联,这是策略模式
  • 用户访问页面时先过登录校验、再过权限校验、最后才能进入页面,这是职责链模式

所以两者虽然都在做“解耦”,但一个偏“选择”,一个偏“传递”。

小结

上面介绍了Javascript中非常经典的职责链模式,它的核心思想就是:将多个处理节点串成一条链,让请求沿着链条依次传递,直到被处理或者终止。

对于日常开发来说,职责链模式非常实用,像路由守卫、请求拦截器、表单校验、中间件机制等场景,都能看到它的影子。它可以让复杂流程拆分得更清晰,也更方便后续扩展和调整顺序。

往期回顾

2.1w Star 的 pretext 火在哪?

2026年3月31日 11:41

我以前一直把“文本测量”当成前端里的脏活:临时挂一个隐藏节点,读一次高度,删掉节点,继续写业务。 直到有天聊天列表在真机上连续掉帧,我才意识到:我们不是在测文字,我们在反复打断浏览器的布局流水线。

pretext 的爆火,不是因为它又造了一个“测高函数”。 它真正击中的,是前端一个长期被忽略的高频痛点:把文本布局这件事,从 DOM 读写里剥离出来,变成可缓存、可复用、可预测的纯计算。

金句:性能问题的本质,常常不是“算太慢”,而是“算错了地方”。

01 为什么 pretext 会突然爆:它动的是浏览器最贵的一段路径

在传统方案里,我们为了拿到多行文本高度,通常会走这条链路:

  • 写入文本到 DOM
  • 触发布局计算
  • 读取 offsetHeight 或 getBoundingClientRect
  • 宽度变化后再来一遍

这条路的问题不是“不能用”,而是它在高频场景里代价陡增。比如虚拟列表、聊天会话流、AI 实时生成文案预览,只要宽度、字体、内容有变化,就会不断触发 reflow。你以为只是读了一个高度,实际上让浏览器把前后文的布局账单都结了一次。

pretext 的思路是反着来:尽量在计算层解决问题,不把浏览器渲染引擎拉进每一次循环。

Before:每次测量都依赖 DOM 状态。 After:先做一次准备,后续只做纯计算。

金句:把高频路径从“读布局”改成“算布局”,才是前端性能优化的分水岭。

02 它到底做了什么:prepare 一次,layout 多次

pretext 的核心设计是双阶段:

  • prepare:一次性做文本分段、空白规则处理、测量并缓存
  • layout:在给定宽度和行高下,快速计算高度与行数

官方 README 给出的基准(500 条文本批次)是:

  • prepare 约 19ms
  • layout 约 0.09ms

这组数字的意义不在“绝对快到离谱”,而在“冷热路径拆分成功”:

  • 冷路径允许稍重,因为执行次数少
  • 热路径必须极轻,因为会反复触发

这和我们在前端做的图片解码缓存、列表虚拟化、请求去重,本质是同一种工程思维:一次准备,多次消费。

金句:真正的优化,不是把每一步都做快,而是让最常走的那一步足够便宜。

03 这不是玩具库:它正好命中四类高价值场景

场景A:虚拟列表动态高度

你最怕的是估高不准导致滚动跳动。pretext 可以在渲染前预测高度,减少“先猜再纠正”的抖动链路。

场景B:聊天与评论流

消息内容、语言、emoji 混排复杂,传统隐藏节点测量会放大性能抖动。pretext 对多语言和 mixed-bidi 处理更稳,适合高并发文本流。

场景C:Canvas 或 SVG 或 WebGL 文本布局

这些场景本来就不依赖 DOM 文本节点。pretext 提供了按行输出和逐行推进能力,天然适配自绘渲染。

场景D:AI 生成 UI 的预校验

在“先生成后渲染”的工作流里,你可以先做离线布局校验,提前发现按钮文案换行、卡片标题溢出等问题,减少回归成本。

金句:当业务进入“文本即数据流”阶段,布局必须从渲染副作用里独立出来。

04 实战接入:用在 React 列表里,别把收益写没了

下面这段是一个可直接改造的思路(关键在缓存 prepared):

import { prepare, layout } from "@chenglou/pretext";

const preparedCache = new Map();

function getPrepared(text, font) {
  const key = font + "::" + text;
  if (!preparedCache.has(key)) {
    preparedCache.set(key, prepare(text, font));
  }
  return preparedCache.get(key);
}

export function measureMessageHeight(text, width) {
  const font = "14px PingFang SC";
  const lineHeight = 22;
  const prepared = getPrepared(text, font);
  const result = layout(prepared, width, lineHeight);
  return result.height;
}

落地时有三个动作不能省:

  • 字体声明必须和真实渲染一致(字号、字重、字族)
  • lineHeight 必须和 CSS 保持一致
  • resize 时优先重跑 layout,不要反复重跑 prepare

金句:你以为在优化库,真正优化的是“调用方式”。

05 先把边界看清:哪些坑会让你误判 pretext

pretext 不是完整字体引擎,它有明确边界,理解边界比盲目神化更重要:

  • 默认目标接近 white-space normal、word-break normal、overflow-wrap break-word 这组常见网页配置
  • 需要保留空格、制表符、换行时,要显式开 whiteSpace pre-wrap
  • macOS 上 system-ui 对精度不安全,建议使用命名字体
  • 极窄宽度下会在字素边界内断词,这是默认换行策略带来的可预期行为

如果你的业务是高级排版软件级需求,仍要做更重的排版系统;但如果你是互联网产品中的高频文本布局,这个边界已经覆盖绝大多数核心场景。

金句:工程价值从不等于“全能”,而在于“对主战场足够强”。

06 我对这波 pretext 的判断:它是方法论信号,不只是新库红利

我更在意的,不是“又多了一个 npm 包”,而是它提醒前端团队一件事: 我们习惯把布局问题交给浏览器兜底,但在高频业务里,布局本身就是业务性能的一部分。

pretext 给出的答案是:

  • 把高频测量从渲染副作用中抽离
  • 把一次性成本前置
  • 把热路径压到可忽略级别

这套方法不会只停在文本领域。它会继续影响我们处理图片裁切、卡片排布、可视化标注、甚至 AI 驱动 UI 生成的方式。

如果你正被列表抖动、消息流掉帧、布局回流困住,pretext 值得你今天就开一个分支实测。 别先问“它能不能替代一切”,先问“它能不能救你最贵的那段路径”。


07 实现原理拆解:pretext 怎么把“排版”变成“计算”

pretext 的核心不是一个“测高函数”,而是一条四层流水线:

输入文本 -> 文本分析(analysis)-> 宽度测量(measurement)-> 断行决策(line-break)-> 输出行数/高度

1)analysis:先把自然语言变成可计算 token

prepare() 的前半段先做结构化,而不是直接测宽:

  • white-space 规则归一化(normal / pre-wrap
  • Intl.Segmenter 切分(覆盖 CJK、泰语、阿拉伯语)
  • 给每段打上 break kind:text / space / tab / hard-break / soft-hyphen / zero-width-break / glue
  • 做浏览器行为导向的合并:URL 连段、数字串、标点链、CJK 禁则、阿拉伯/缅文粘连规则

这一层决定了“断行质量上限”。analysis 对,后面才有可能又快又准。

金句:性能优化的第一步,不是算得更快,而是先把问题描述正确。

2)measurement:把 token 变成宽度数组(并缓存)

prepare() 后半段用 canvas 测量,不碰 DOM 布局树:

  • OffscreenCanvas / canvas.measureText 测宽
  • (font, segment) 做缓存,避免重复测量
  • 对可断长词预计算 grapheme 宽度(breakableWidths
  • 维护两套行尾宽度:lineEndFitAdvances(判定能否放入)与 lineEndPaintAdvances(最终绘制宽度)

关键细节是 emoji 校正:在 Chromium/Firefox 的某些字体尺寸下,canvas 对 emoji 可能偏宽,pretext 会做一次 canvas 与隐藏 DOM span 的校准,把差值缓存成 emojiCorrection

金句:不是所有误差都要消灭,但高频误差必须被驯服。

3)line-break:状态机断行(layout() 的核心)

layout() 本质是跑一个轻量状态机,核心状态很少:

  • 当前行宽 lineW
  • 最近合法断点 pendingBreak
  • 当前游标 (segmentIndex, graphemeIndex)

决策顺序大致是:

  1. 先尝试整段放入当前行
  2. 超宽优先回退到最近合法断点
  3. 无断点且为长词时,按 grapheme 粒度硬断(overflow-wrap: break-word
  4. 命中 soft-hyphen 时补上可见 - 的宽度
  5. tabhard-break、行尾空白走专门分支

此外还有 EngineProfile 做浏览器差异收敛(Safari/Chromium 的 epsilon 与策略差异)。它不是理想化排版器,而是贴近真实浏览器行为的工程实现。

金句:断行不是数学题,而是带浏览器个性的工程博弈。

4)为什么它能快:冷热路径拆分

  • prepare(text, font):重活前置(分析、测量、缓存)
  • layout(prepared, maxWidth, lineHeight):热路径只做数组遍历与加减比较

所以 resize 时只重复 layout,不重测文字、不触发 DOM reflow。性能收益来自“重活前置 + 热路径极简”。

5)一段伪代码看完整链路

const prepared = prepare(text, font)
// 内部:analysis -> measurement -> cache arrays

const { lineCount, height } = layout(prepared, width, lineHeight)
// 内部:line-break state machine only

// width 变化时:
const next = layout(prepared, nextWidth, lineHeight)
// 不重新测量,不碰 DOM

6)工程启发

pretext 的方法论可以迁移到更多前端场景:先把“渲染副作用问题”改写为“可缓存的数据问题”,再让高频路径退化成纯计算。

收束金句:当布局从副作用变成数据流,性能就从玄学变成工程。

参考链接:

收束金句: 把重复测量交给预计算,把浏览器主线程还给真正的交互。

2024-2025 JavaScript 最新语法糖:这10个新特性,让你的代码优雅到同事看不懂

作者 小哈猪
2026年3月31日 11:25

2024-2025 JavaScript 最新语法糖:这10个新特性,让你的代码优雅到同事看不懂

JavaScript 每年都在进化,TC39 每年都会推送一些让代码更简洁、更易读的新特性。本文整理 2024-2025 年最值得掌握的 10 个新语法,从 ES2022 到 ES2025,带你感受什么叫"原来 JS 也可以这么优雅"。


一、ES2022 早已落地,但你可能还没用

1. # приватные поля — 真正的私有属性

之前我们用 _name 下划线约定来"假装"私有,但 JavaScript 给了我们真正的私有字段。

老写法:

class User {
  constructor(name, password) {
    this.name = name
    this._password = password // 这只是约定,谁都能访问
  }

  getPassword() {
    return this._password
  }
}

新写法:

class User {
  #password        // 真私有,外部无法访问
  #token

  constructor(name, password) {
    this.name = name
    this.#password = password
  }

  validate(input) {
    return this.#checkPassword(input) // 类内部任意方法都能访问
  }

  #checkPassword(input) {            // 私有方法也一样
    return input === this.#password
  }
}

const u = new User('张三', '123456')
u.name              // ✅ '张三'
u.#password         // ❌ SyntaxError: Private field '#password' must be declared

好处: 编译期就报错,不是运行时才暴露,数据封装从"约定"变成"强制"。


2. .at() — 终于可以优雅地取倒数第N个

老写法:

const arr = [1, 2, 3, 4, 5]
arr[arr.length - 1]   // 5,要写这么长...
arr[arr.length - 2]  // 4,痛苦

新写法:

const arr = [1, 2, 3, 4, 5]
arr.at(-1)   // 5 ✅
arr.at(-2)   // 4 ✅

// 字符串也行
'hello'.at(-1)  // 'o'

好处: 代码意图清晰,不再需要 length - 1 这种绕弯子的写法。


3. Object.hasOwn() — 比 in 更安全的属性检测

老写法:

const obj = { a: 1 }

// ❌ 陷阱:in 会遍历原型链,可能误判
console.log('constructor' in obj)  // true(来自原型链!)

// ✅ 正确但冗长
console.log(Object.prototype.hasOwnProperty.call(obj, 'a')) // true

新写法:

const obj = { a: 1 }
console.log(Object.hasOwn(obj, 'a'))    // true
console.log(Object.hasOwn(obj, 'constructor')) // false ✅,不误判原型链

好处: 更简洁,更安全,不用记那个超长的 hasOwnProperty.call


4. error.cause — 错误链追踪,终于不用手动传参了

老写法:

function fetchData() {
  try {
    JSON.parse('invalid json')
  } catch (e) {
    // 手动包装,繁琐
    throw new Error('获取数据失败', { cause: e })
  }
}

新写法:

function fetchData() {
  try {
    JSON.parse('invalid json')
  } catch (e) {
    // 第三参数直接指定 cause,自动传递
    throw new Error('获取数据失败', { cause: e })
  }
}

try {
  fetchData()
} catch (e) {
  console.log(e.cause)        // SyntaxError: Unexpected token...
  console.log(e.cause.message) // 直接拿到原始错误信息
}

二、ES2023 — 小但实用的改进

5. 数组的 .toSorted().toReversed().toSpliced() — 不修改原数组

老写法:

const nums = [3, 1, 4, 1, 5]
nums.sort()      // 修改了原数组!
nums.reverse()   // 又修改了!

新写法:

const nums = [3, 1, 4, 1, 5]

const sorted = nums.toSorted()    // 返回新数组,原数组不变 ✅
const reversed = nums.toReversed() // 返回新数组,原数组不变 ✅
const spliced = nums.toSpliced(1, 2) // 同理,返回新数组

console.log(nums) // [3, 1, 4, 1, 5] 完好无损
console.log(sorted) // [1, 1, 3, 4, 5]

好处: 再也不用 concat([...nums]) 这种 hack 写法了,代码意图一目了然。


6. hash 式的 WeakMap / WeakSet — 手动 GC 控制

// 新的 Symbol key 版本,让 WeakMap/WeakSet 更实用
const cache = new WeakMap()
const obj = { id: 1 }
cache.set(obj, 'cached result')

// 不再只能用对象作为 key 了
const map = new WeakMap()
map.set(123, 'number key')  // ✅ ES2023 支持基本类型作为 WeakMap key

三、ES2024 — 让人眼前一亮的重磅功能

7. Array.prototype.groupBy — 数据分组的利器

老写法:

const products = [
  { name: '手机', category: '数码', price: 3000 },
  { name: 'T恤', category: '服装', price: 200 },
  { name: '电脑', category: '数码', price: 8000 },
  { name: '裤子', category: '服装', price: 150 },
]

// 写一个 reduce 来分组
const grouped = products.reduce((acc, item) => {
  if (!acc[item.category]) acc[item.category] = []
  acc[item.category].push(item)
  return acc
}, {})

// 结果:{ '数码': [...], '服装': [...] }

新写法:

const products = [
  { name: '手机', category: '数码', price: 3000 },
  { name: 'T恤', category: '服装', price: 200 },
  { name: '电脑', category: '数码', price: 8000 },
  { name: '裤子', category: '服装', price: 150 },
]

// 一行搞定!
const grouped = Object.groupBy(products, item => item.category)
const priceMap = Object.groupBy(products, item => item.price > 1000 ? 'expensive' : 'cheap')

console.log(grouped)
// {
//   '数码': [{ name: '手机', ... }, { name: '电脑', ... }],
//   '服装': [{ name: 'T恤', ... }, { name: '裤子', ... }]
// }

好处: 数据处理代码从 5-6 行变成 1 行,可读性大幅提升。


8. 正则表达式的 v flag — Unicode 模式升级

// 以前:处理 emoji 和复杂 Unicode 字符会出问题
/^\p{Emoji}/.test('👋')  // ❌ 报错,需要 u flag

// v flag:更好的 Unicode 字符类处理
/^\p{RGI_Emoji}$/v.test('👋')           // ✅
/^\p{Script=Han}+$/v.test('你好世界')   // ✅ 判断是否全是汉字

// 集合运算,之前的 u flag 做不到
/^[\p{ASCII}&&[\p{Emoji}]]+$/v.test('😀') // false,只有 emoji 才是 emoji

四、ES2025 — 前沿预览,已经可以在 Node 22/Bun 中使用

9. Promise.withResolvers() — 告别 new Promise 的样板代码

老写法:

function fetchSomething() {
  let resolve, reject
  const promise = new Promise((res, rej) => {
    resolve = res
    reject = rej
  })

  setTimeout(() => resolve('data'), 1000)
  return { promise, resolve, reject }
}

新写法:

function fetchSomething() {
  const { promise, resolve, reject } = Promise.withResolvers()

  setTimeout(() => resolve('data'), 1000)
  return { promise, resolve, reject }
}

// 或者更简洁的场景
async function loadConfig() {
  const { promise, resolve } = Promise.withResolvers()
  window.addEventListener('config-loaded', () => resolve(), { once: true })
  return promise
}

好处: 省去 5 行样板代码,语义更清晰。


10. Array.prototype.findLast() — 从后往前找

const scores = [85, 92, 73, 88, 95, 78]

scores.find(n => n > 80)      // 92(从前往后找第一个)
scores.findLast(n => n > 80)  // 95(从后往前找第一个)✅

// 之前要这么写:
[...scores].reverse().find(n => n > 80) // 先 reverse,太蠢了

五、TypeScript 独享的语法糖(但你应该在用)

装饰器(正式稳定)

// TypeScript 5.x+,装饰器正式稳定
function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value
  descriptor.value = function (...args: any[]) {
    console.log(`调用 ${key},参数:`, args)
    return original.apply(this, args)
  }
  return descriptor
}

class Calculator {
  @log
  add(a: number, b: number) {
    return a + b
  }
}

const calc = new Calculator()
calc.add(1, 2)  // 输出:调用 add,参数: [1, 2]
                // 返回: 3

总结:这些语法糖的实际价值

特性 核心价值 日常可用性
#私有字段 真正的封装 ✅ 立刻用
.at() 代码简洁 ✅ 立刻用
Object.hasOwn() 更安全的检测 ✅ 立刻用
.toSorted() 系列 不破坏原数据 ✅ 立刻用
groupBy 一行分组 ✅ 立刻用
Promise.withResolvers() 减少样板代码 ✅ 立刻用
findLast() 更直观的数据查找 ✅ 立刻用
正则 v flag 处理 emoji/Unicode ⚠️ 逐步迁移
装饰器 AOP 编程 ⚠️ TS 项目可用

这些新特性有一个共同点:不是为了炫技,而是让代码意图更清晰、bug 更少。

能用新语法就用新语法,现代 Node 22 和 Chrome 122+ 已经完全支持以上所有特性。


如果这篇文章有帮助,欢迎点赞收藏。你的项目里用上了几个新特性?评论区见~

Pretext 初识——零 DOM 测量的文本布局引擎

2026年3月31日 11:23

给你一段文本和一个容器宽度,怎么知道它会占几行?

传统做法是创建 DOM 元素 → 设置文本内容 → 渲染 → 调用 getBoundingClientRect() 读取高度。这个流程会触发layout reflow ——浏览器渲染管线里面性能消耗最大的操作之一。

DOM 测量的代价

你可能见过浏览器控制台的这个警告:

[Violation] Forced reflow while executing JavaScript took 47ms

这就是强制回流:JavaScript 查询几何属性(offsetWidthclientHeight)时,如果 DOM 状态已经改了,浏览器被迫同步重算样式和布局。

而且布局几乎总是作用于整个文档,元素多了确定位置和尺寸就很慢。有测试显示每帧花超过 28ms 在布局上,而流畅动画要求 16ms 内完成一帧。

在循环里频繁读写 DOM,浏览器会反复计算整个页面的布局,这就是布局抖动(layout thrashing)

虚拟列表的"鸡生蛋"问题

react-virtualized 有个 issue:动态高度虚拟列表在滚动到最底部后往上滚,单元格会"跳跃"。

原因是列表从底部加载新内容时,之前渲染的项目高度变了,但滚动位置没跟着调。维护者的回应让人意外:

"I don't know a way to avoid this"

一个几万 star 的库,直接说"不知道怎么避免"。不过这倒不是开发者技术不行,而是根本性的架构问题:虚拟列表需要预先知道每个项目的高度才能正确渲染,但文本内容的高度只有渲染后才能测量。经典的"鸡生蛋"问题。

现在的选择就是:要么预先渲染所有项(卡顿),要么估算高度(不准确,滚动条跳动)。

Canvas 测量也不是银弹

既然 DOM 测量这么慢,用 Canvas 的 measureText() 行不行?

Recharts 也遇到过这个问题——刻度太多时性能下降,原因是计算刻度可见性时频繁调用 Canvas 测量。最后的优化方案是减少 DOM 测量,实现了 1.8 倍加速,Canvas 文本测量也不是银弹。

Ejecta 项目甚至直接开了个 issue 叫 "measureText is slow",开发者说只能靠缓存已测量的字符串结果来绕过。

Pretext 的思路:用计算换 I/O

Pretext 的核心思路是用纯算术计算代替 DOM 测量,文本布局完全不碰 DOM,性能提升 50-100 倍。

两阶段设计:

  1. prepare(): 一次性重工作(文本分析 + Canvas 测量 + 缓存)
  2. layout(): 纯算术换行计算(零 DOM、零分配、零 I/O)

性能数据:

  • prepare(): 18.85ms / 500 文本(一次性)
  • layout(): 0.09ms / 500 文本(快 210 倍)
  • DOM batch: 4.05ms(慢 45 倍)
  • DOM interleaved: 43.50ms(慢 483 倍)

核心设计

两阶段分离

Pretext 的核心思路是把繁重的预处理和轻量的布局计算分开

// 一次性预处理
const prepared = prepare('这是一段文本', '16px Inter')

// resize 时反复调用,零 DOM 访问
const { height, lineCount } = layout(prepared, 300, 20)
const newResult = layout(prepared, 400, 20)  // 改变宽度

prepare() 做什么?

  • 空白归一化(CSS white-space 语义)
  • 智能分词(使用 Intl.Segmenter
  • 合并规则(标点附着、URL 保持完整等)
  • Canvas 测量每个片段的宽度
  • 三级缓存(字体 → 片段 → 字素)

layout() 做什么?

  • 纯算术计算:累加宽度,判断换行
  • 零 I/O:不读 DOM,不调 Canvas
  • 零分配:不创建字符串、数组、对象
  • 零回溯:贪婪算法 + pending break 机制

性能对比

操作 耗时 相对速度 说明
prepare() 18.85ms 1x 一次性预处理(500 文本)
layout() 0.09ms 210x 纯算术计算(500 文本)
DOM batch 4.05ms 45x 慢 批量 DOM 测量
DOM interleaved 43.50ms 483x 慢 交错读写 DOM

实际意义:

  1. 实时 resize 不再卡顿:0.09ms vs 43.50ms,流畅度提升 483 倍
  2. 虚拟列表可以预知高度,无需渲染即可测量
  3. Canvas 动态布局成为可能,游戏 UI、图表标签不再是瓶颈
  4. 瀑布流可以实时计算,不需要预先渲染

Pretext 的牛逼之处

Pretext 本质上是用 JavaScript 把浏览器的文本布局引擎重新实现了一遍,但只暴露必要的接口。核心不是"更快",而是"更聪明"——预处理阶段完成所有复杂工作(分词、测量、缓存),布局阶段只做纯数值计算,再利用三级缓存避免重复测量。

与现有方案对比

方案 准确性 性能 复杂度 适用场景
DOM 测量 ✅ 高 ❌ 慢 ✅ 简单 静态页面
Canvas 测量 ✅ 高 ⚠️ 中 ⚠️ 中 图表、游戏
预估高度 ❌ 低 ✅ 快 ✅ 简单 虚拟列表(妥协)
Pretext ✅ 高 极快 ⚠️ 高 通用

Pretext 既准确又快,代价是实现更复杂。对于频繁 resize、虚拟列表、Canvas 渲染这些场景,这个代价是值得的。


技术细节

文本分析:从字符到片段

Pretext 的文本分析阶段比较复杂,包括:

1. 空白归一化

根据 CSS white-space 模式:

  • normal: 合并连续空格、Tab、换行为单个空格
  • pre-wrap: 保留空格、Tab、硬换行

2. 智能分词

使用 Intl.Segmenter 进行语言感知的分词:

  • 英文:按词分("Hello world"["Hello", " ", "world"]
  • 中文:按字分("你好世界"["你", "好", "世", "界"]
  • 泰语:按词边界分(泰语没有空格,需要词典分词)

3. 高级合并规则

这些规则是 Pretext 准确性的关键:

规则 输入 输出 原因
标点附着 "word" + "." "word." 避免句号前换行
URL 合并 "https:" + "/" + "/" "https://..." 保持 URL 完整
数字序列 "7" + ":" + "00" "7:00" 时间/日期保持完整
NBSP 粘合 "word" + NBSP "word" + glue 防止在 NBSP 处换行

4. CJK 禁则处理

中日韩语言有特殊的排版规则——行首禁则(逗号、句号、感叹号不能出现在行首)和行尾禁则(左括号、引号不能出现在行尾),确保文本换行符合东亚排版习惯。

换行算法:为什么不用 Knuth-Plass

Knuth-Plass 算法是 TeX 排版系统用的最优换行算法,能产生最均匀的词间距。但浏览器不用它,原因不是性能。

CSS 规范要求浮动元素必须"尽可能高"定位,而 Knuth 换行算法可能导致某个词不是尽可能高的。唯一能满足这个规范的算法是贪婪算法。浏览器用贪婪算法不是性能问题,而是规范约束

Pretext 也选了贪婪算法,原因有三:

  1. 性能:O(n) vs O(n²),快几个数量级
  2. 浏览器一致性:与 CSS 行为对齐
  3. 实用性:大多数场景下贪婪算法的质量足够好

实际对比:Knuth-Plass 9 行 vs 浏览器贪婪 10 行,质量差异不大,性能差异很大。

快速路径优化

Pretext 内部有个 simpleLineWalkFastPath 标志:

if (prepared.simpleLineWalkFastPath) {
  // 简单算法:只处理 text + space
  return countPreparedLinesSimple(prepared, maxWidth)
}

// 完整算法:处理软连字符、Tab、Glue、硬换行等
return walkPreparedLines(prepared, maxWidth, onLine)

满足这些条件时走快速路径:不含 soft-hyphen、tab、glue(NBSP 等)、hard-break(\n)。简单文本直接跳过复杂逻辑,提升性能。

游标系统:精确定位与流式布局

游标系统是 Pretext 最有创新性的设计之一:

type LayoutCursor = {
  segmentIndex: number   // 第几个片段
  graphemeIndex: number  // 片段内的第几个字素
}

为什么需要两层索引?单层索引无法定位到片段内部的字符。比如一个包含 10 个汉字的片段,没法指定第 5 个字,两层索引解决了这个问题。

游标系统支持流式布局和变宽布局:

let cursor = { segmentIndex: 0, graphemeIndex: 0 }

while (true) {
  // 每行可以有不同的宽度
  const width = y < imageHeight ?
    columnWidth - imageWidth : columnWidth

  const line = layoutNextLine(prepared, cursor, width)
  if (!line) break

  renderLine(line, y)
  cursor = line.end
  y += lineHeight
}

这段代码实现了文本绕图流动:图片上方行宽较小,图片下方恢复全宽。传统 CSS 实现需要复杂的布局技巧,Pretext 几行代码搞定。

Emoji 修正:浏览器 bug 的工程解法

不同浏览器的 Canvas 文本测量结果有差异,尤其是 Emoji:字号小于 24px 时 Canvas 测量的 emoji 宽度比 DOM 宽,原因是 Apple Color Emoji 字体的 Canvas 测量 bug。

Pretext 的解决方案:

  1. 检测修正量:对比 Canvas vs DOM 测量,计算差值
  2. 缓存修正量:每个字号只检测一次
  3. 应用修正correctedWidth = canvasWidth - emojiCount × correction

关键发现:修正量只跟字号相关,跟字体无关,修正量可以缓存复用。

零分配热路径

layout() 的设计目标:

  • ❌ 不创建字符串、数组、对象
  • ❌ 不读取 DOM
  • ❌ 不调用 Canvas
  • ✅ 纯数值计算

并行数组 vs 对象数组:

// ✅ 并行数组(缓存友好)
widths: number[]          // [42.5, 4.4, 37.2]
kinds: SegmentBreakKind[] // ['text', 'space', 'text']

// ❌ 对象数组(指针追踪)
segments: { width: number, kind: SegmentBreakKind }[]

并行数组的内存布局是连续的,CPU 缓存命中率高,V8 的隐藏类优化更好,这是 Pretext 性能的底层保障。


解锁新的 UI 可能性

高性能虚拟列表

传统方案先渲染再测量(卡顿),估算方案不准确(滚动条跳动),react-virtualized 维护者直接说 "I don't know a way to avoid this"

Pretext 方案:

// 列表渲染前,预先测量所有项
const items = data.map(item => ({
  ...item,
  prepared: prepare(item.text, '14px Inter')
}))

// 滚动时,实时计算可见范围
function onScroll() {
  const visibleItems = items.filter(item => {
    const { height } = layout(item.prepared, containerWidth, 20)
    return isItemVisible(item, height)
  })

  // 只渲染可见项,零强制回流
}

1000 项的长列表,滚动时每帧只需 0.09ms。

流式布局:变宽与绕图

图文混排,文字绕着图片流动,每行宽度不同:

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0

while (cursor.segmentIndex < prepared.widths.length) {
  const lineWidth = y < imageHeight ?
    columnWidth - imageWidth : columnWidth

  const line = layoutNextLine(prepared, cursor, lineWidth)
  if (!line) break

  renderLine(line, y)
  cursor = line.end
  y += lineHeight
}

类似 Notion、Figma 的复杂布局,Web 原生就能实现。

Canvas 富文本渲染

Recharts 刻度性能问题的根源是频繁调用 measureText(),加上 Chromium 的字体切换成本也高。

Pretext 的方案:

// 预处理阶段:批量测量,利用缓存
const prepared = prepareWithSegments(longText, '16px Inter')

// 渲染阶段:零 Canvas 测量
const { lines } = layoutWithLines(prepared, canvasWidth, 20)

lines.forEach((line, i) => {
  ctx.fillText(line.text, 0, i * 20)  // 直接绘制
})

图表、游戏、可视化应用的性能瓶颈可以被打破。Recharts 如果用 Pretext,性能至少提升 1.8 倍。

紧凑布局(Shrinkwrap)

聊天气泡、标签、卡片需要找到最紧凑的容器宽度:

// 二分搜索最优宽度
let min = 100, max = 500
while (max - min > 1) {
  const mid = (min + max) / 2
  const { lineCount } = layout(prepared, mid, 20)

  if (lineCount <= 2) max = mid  // 最多 2 行
  else min = mid
}

const optimalWidth = max  // 最紧凑的宽度

WhatsApp、WeChat 的聊天气泡就是典型应用。

双栏流式排版

报纸、杂志、电子书的连续流动排版:

const columns = [
  { x: 0, width: 300 },
  { x: 320, width: 300 }
]

let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let currentCol = 0

while (cursor.segmentIndex < prepared.widths.length) {
  const col = columns[currentCol]
  const line = layoutNextLine(prepared, cursor, col.width)

  if (!line) break

  if (line.end.segmentIndex >= prepared.widths.length * 0.5 && currentCol === 0) {
    currentCol = 1
  }

  renderLine(line, col.x)
  cursor = line.end
}

Flow ePub 阅读器就是这种需求。


性能与权衡

性能数据

Chrome 基准测试(500 文本批次):

操作 耗时 说明
prepare() 18.85ms 一次性预处理
layout() 0.09ms 纯算术,快 210 倍
DOM batch 4.05ms 慢 45 倍
DOM interleaved 43.50ms 慢 483 倍

不同语言的性能差异:

语言 prepare() 耗时 片段数 原因
中文 6.10ms 5,433 → 7,949 更多片段
泰语 13.50ms 10,281 无空格分词
阿拉伯语 63.50ms 37,603 RTL + 连字

设计权衡

贪婪算法 vs 最优断行 — 0.09ms vs 数百 ms,9 行 vs 10 行,差异不大,选择性能优先。

预处理成本 vs 布局速度 — 一次重 19ms,多次轻 0.09ms。适合 resize 频繁、虚拟列表、Canvas 渲染的场景,不适合一次性渲染的静态页面。

并行数组 vs 对象数组 — 内存布局优化、缓存友好,代价是代码可读性下降,需要索引对应。

当前限制

不支持的 CSS 配置:

  • word-break: break-all / keep-all
  • ❌ 自动连字符(hyphenation,仅支持软连字符)
  • line-break: strict / loose / anywhere
  • overflow-wrap: anywhere

已知问题:

  • ⚠️ system-ui 字体的 macOS 陷阱(Canvas 和 DOM 解析不同)
  • ⚠️ 需要 Intl.Segmenter 支持(现代浏览器都支持)

最后

pretext 这项目确实有点意思,以上都是我一些初步的探索和了解,如有错误,欢迎指正

参考文献

  1. DebugBear. (2022). How To Fix Forced Reflows And Layout Thrashing. debugbear.com/blog/forced…
  2. web.dev. Avoid large, complex layouts and layout thrashing. web.dev/articles/av…
  3. react-virtualized Issue #610. (2018). github.com/bvaughn/rea…
  4. vue-virtual-scroller Issue #767. (2021). github.com/Akryum/vue-…
  5. Recharts Issue #3983. (2021). github.com/recharts/re…
  6. W3C CSSWG Issue #3756. Float positioning "as high as possible" prohibits non-greedy line-breaking. github.com/w3c/csswg-d…
  7. tldraw Issue #7377. (2023). Batch measurement optimization. github.com/tldraw/tldr…
  8. bramstein/typeset. TeX line breaking algorithm in JavaScript. github.com/bramstein/t…
  9. Figma Blog. (2021). How we figured out canvas virtualization. www.figma.com/blog/how-we…
  10. GitHub Issue #427. (2024). Canvas text rendering and metrics (2024 edition). github.com/web-platfor…

项目地址github.com/chenglou/pr…

npm插件的开发详细流程

作者 飞扬rdh
2026年3月31日 11:08

1注册npm账号

[在这里注册一个npm账号](npm | Home)

2.生成自己的token

25年11月改版后, 需要添加token验证, 来绕过双因素认证, 否则在发布的时候会提示权限不足

  • 点击账号, 选择Access Toekens
  • 在这个页面点击 Generate New Token
  • 在生成token页面, 需要注意,一定要勾选“Bypass two-factor authentication (2FA)”
  • Packages and scopes部分按照自己的业务规划 添加就可以了
  • 在项目添加.npmrc或者全局设置.npmrc的authToken

流程图如下:

    • image.png
  1. image.png
  2. image.png

开发前端插件

  • npm插件开发完成后, 执行npm login 登录账号
  • 会提示输入账号和密码,以及一次性验证码,一次输入即可
  • 如果不确定是否已经登录 可以执行npm whoami 返回账号信息 即是已登录状态
  • 设置packages.json内的version插件的版本号
  • 执行npm publish即可
  • 在个人的npm的packages内可以看到已经发布的npm插件

本地调试npm插件

  1. 进入到本地npm包对应的文件内,执行
npm link

执行成功会有提示, 一般会返回对应包的名字 或者 返回将全局的node_modules指向了本地开发的npm插件地址

  1. 进入到业务系统
npm link "开发的插件名"
  1. 关闭断开 在包的目录下执行:
npm unlink 

版本更新

  1. 完成功能的开发,bug修复, 提交代码
git add . 
git commit -m "feat: 更新了什么?"
  1. 质量与构建校验
npm run lint 
npm run test 
npm run build # 生成 dist 等产物

检查 package.jsonprivate 设为 falsemain/module 入口正确,files 字段指定要发布的文件。 3.版本号规则

  • patch(修订号) :Bug 修复、不影响功能的小改动 → 1.0.0 → 1.0.1

  • minor(次版本号) :新增功能、向后兼容 → 1.0.0 → 1.1.0

  • major(主版本号) :不兼容的重大变更 → 1.0.0 → 2.0.0

  1. 更新版本号
  • 方式1: 使用 npm version 命令自动修改 package.json 并创建 Git Tag:
# 修订版
npm version patch 
# 次版本 
npm version minor 
# 主版本
npm version major

执行后会自动更新package.json与package-lock.json的version

  • 手动更新: 直接编辑package.json 修改versiob字段, 在手动打tag
# 编辑 package.json"version": "1.0.1" 
git add package.json package-lock.json 
git commit -m "chore: bump version to 1.0.1" 
git tag v1.0.1
  1. 发布到npm
# 常规发布 
npm publish 
# 发布预发布版本(beta/alpha) 
npm publish --tag beta 
npm publish --tag alpha
# 查看已发布版本 
npm view <包名> versions
  1. 推送git变更与标签
git push origin main # 推送到主分支 
git push --tags # 推送版本标签

AI Harness - 2026 AI 工程新范式

作者 俊劫
2026年3月31日 11:07

🚀 前端工程师如何构建 AI Harness(架构 + 代码设计 + 落地实践)

一篇写给“已经开始做 AI,但不想停留在 Demo 阶段”的前端工程师


一、问题背景:为什么你需要 AI Harness?

很多团队在接入 AI 后,很快会遇到这些问题:

  • ❓ prompt 改了一点,效果变差但不知道为什么
  • ❓ 不同模型表现不一致,无法稳定
  • ❓ AI 输出偶尔离谱,但无法复现
  • ❓ 每次优化都像“玄学调参”
  • ❓ 没有办法做回归测试

这些问题的本质只有一个:

你在“用 AI”,但没有“工程化 AI”


二、什么是 AI Harness?

一句话定义:

AI Harness = 让 AI 从“能用”变成“可控、可测、可观测、可回归”的工程系统

从 Prompt 到 Harness

随着 AI 处理任务复杂度的增加,工程重点经历了三个阶段的演进:

阶段 核心关注点 隐喻 解决的问题
Prompt Engineering (2023) 说什么 指令 如何通过提示词让 AI 交付单次结果。
Context Engineering (2025) 知道什么 信息 如何通过 RAG 和动态上下文构建让 AI 获得所需信息。
Harness Engineering (2026) 在什么环境做事 环境/闭环 如何构建约束、反馈与控制系统,让 Agent Reliable 执行任务。

一个直观类比

AI 组件 类比
LLM 发动机
AI Harness 方向盘 + 仪表盘 + 测试系统

为什么它重要?

现实一点说:

  • 模型能力:越来越接近
  • 工程能力:差距巨大

👉 真正的壁垒在这里:

谁能稳定地“用好 AI”,而不是谁能调用 API


三、整体架构(前端可落地版)

┌──────────────────────────────────────────────┐
│                  Frontend App                │
│ Chat / Admin / SaaS / Tooling UI             │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│               AI Service Layer               │
│ prompt / model / tool / parser / retry       │
└──────────────────────────────────────────────┘
                     │
     ┌───────────────┼───────────────┐
     ▼               ▼               ▼
┌───────────┐  ┌─────────────┐  ┌──────────────┐
│ LLM APIs  │  │ Tool System │  │ Eval System  │
└───────────┘  └─────────────┘  └──────────────┘
     │               │               │
     └──────┬────────┴───────┬───────┘
            ▼                ▼
┌──────────────────────────────────────────────┐
│             Observability Layer              │
│ trace / logs / token / latency               │
└──────────────────────────────────────────────┘
                     │
                     ▼
┌──────────────────────────────────────────────┐
│              Regression Harness              │
│ dataset / scoring / CI                       │
└──────────────────────────────────────────────┘

四、核心设计思想(非常关键)

在实现之前,先明确几个核心原则:


1️⃣ Scene(场景驱动)

👉 不要把 AI 当成一个“万能函数”

错误写法:

runAI("帮我干点事")

正确方式:

runAI({
  scene: "bug-fix",
  input,
})

👉 每个场景:

  • prompt 不同
  • 工具不同
  • 模型不同
  • 输出结构不同

2️⃣ 工具优先,而不是 prompt 堆砌

很多人会这样做:

请你分析问题,如果需要可以假设...

👉 这是不稳定的

正确方式:

你可以使用以下工具:
- searchDocs
- runTests
- queryAPI

👉 本质:

用工具约束模型,而不是靠 prompt 想象


3️⃣ 可观测性优先

如果你没有记录:

  • prompt
  • tool 调用
  • 输出

👉 你就无法 debug


4️⃣ Eval 驱动优化

不要这样:

“我感觉这个 prompt 更好了”

要这样:

旧版本:78% pass
新版本:85% pass

五、分层设计(5 层)


1️⃣ UI 层

职责:

  • 输入 / 展示
  • 流式渲染
  • 中断 / 重试
type ChatMessage = {
  id: string
  role: 'user' | 'assistant'
  content: string
  status?: 'streaming' | 'done'
}

2️⃣ AI Service 层(核心)

👉 相当于 AI 的 BFF

负责:

  • prompt 构建
  • model routing
  • tool orchestration
  • output parsing
  • retry / fallback

核心调用方式

const result = await runAI({
  scene: 'bug-fix',
  userInput,
  context,
})

3️⃣ Tool 层

标准定义

export interface AITool<Input, Output> {
  name: string
  description: string
  schema: ZodSchema<Input>
  execute(input: Input): Promise<Output>
}

示例工具

export const searchDocsTool: AITool<
  { query: string },
  { results: string[] }
> = {
  name: 'searchDocs',
  description: 'Search internal docs',
  schema: z.object({
    query: z.string(),
  }),

  async execute(input) {
    return searchDocs(input.query)
  },
}

Tool Registry

const tools = {
  searchDocs: searchDocsTool,
  runTests: runTestsTool,
}

4️⃣ Observability 层

为什么必须有?

否则你会遇到:

  • “昨天还好好的”
  • “偶现 bug”
  • “不知道模型为什么这么答”

Trace 结构

type AITrace = {
  traceId: string
  scene: string
  model: string
  prompt: string
  toolCalls: Array<{
    name: string
    args: unknown
    result: unknown
    duration: number
  }>
  output: unknown
  latency: number
  success: boolean
}

5️⃣ Eval / Regression 层

示例数据集

[  {    "id": "faq-1",    "input": "退款规则是什么?",    "expected": {      "mustInclude": ["7天", "原路退回"]
    }
  }
]

Runner

for (const testCase of dataset) {
  const result = await runAI({
    scene: 'faq',
    userInput: testCase.input,
  })

  const passed = evaluate(result, testCase.expected)
}

六、核心代码结构(推荐)

src/
├── ai/
│   ├── core/
│   │   ├── ai-service.ts
│   │   ├── model-router.ts
│   │   ├── prompt-builder.ts
│   │   ├── output-parser.ts
│   │   └── trace.ts
│   │
│   ├── scenes/
│   │   ├── bug-fix.scene.ts
│   │   ├── faq.scene.ts
│   │
│   ├── tools/
│   │   ├── search-docs.tool.ts
│   │   ├── run-tests.tool.ts
│   │
│   ├── eval/
│   │   ├── datasets/
│   │   ├── run-eval.ts
│   │
│   └── adapters/
│       ├── openai.adapter.ts
│
├── server/
│   └── api/
│
└── apps/
    └── chat/

七、Scene 抽象(关键)

export interface AIScene {
  name: string
  tools?: string[]
  model?: string

  buildPrompt(input): Promise<string>
  parseOutput(raw: string): unknown
}

示例:Bug Fix

export const bugFixScene: AIScene = {
  name: 'bug-fix',
  tools: ['searchDocs', 'runTests'],

  async buildPrompt({ userInput }) {
    return `
You are a senior frontend engineer.

Analyze the bug and return JSON:
{
  "rootCause": "",
  "fixPlan": []
}

Bug:
${userInput}
    `
  },

  parseOutput(raw) {
    return JSON.parse(raw)
  },
}

八、AI Service 实现(核心)

export async function runAI(params) {
  const scene = getScene(params.scene)
  const model = resolveModel(scene.name)

  const prompt = await scene.buildPrompt(params)

  const result = await callModel({
    model,
    prompt,
    tools: scene.tools,
  })

  return scene.parseOutput(result)
}

九、推荐落地场景(非常现实)


1️⃣ 文档问答

👉 提升研发效率
👉 减少重复沟通


2️⃣ Code Review

输入:

  • diff

输出:

  • 风险点
  • 建议

3️⃣ AI 修 Bug(最有价值)

流程:

Bug → 分析 → 检索 → 修复 → 测试 → 输出

十、MVP 落地建议

第一阶段只做:

1. Chat UI
2. runAI
3. 2 个工具
4. trace
5. 10 条测试数据

👉 一周可落地


十一、常见坑


❌ prompt 写死

👉 后期不可维护


❌ 工具不稳定

👉 模型直接崩


❌ 没有 trace

👉 无法 debug


❌ 没有 eval

👉 优化靠感觉


❌ 一上来做 Agent

👉 容易翻车

正确路径:

问答 → 建议 → 半自动 → 自动化

十二、总结

对于前端工程师来说:

👉 AI Harness 是一个非常好的切入点

因为它结合了:

  • 工程能力
  • 架构能力
  • 系统设计
  • AI 应用能力

🔚 最后一句

未来 AI 的差距不在于:

谁会用 AI

而在于:

谁能把 AI 稳定地用在真实业务中


如果你看到这里,说明你已经不是在“用 AI”了。

你是在构建 AI 系统


Harness Engineering 2026 目标已定

Navigation API 如何重塑前端路由

2026年3月31日 10:56

Navigation API 如何重塑前端路由

前阵子给公司的 B 端管理系统做路由层重构,用的还是 Vue Router 4.x,底层跑的 history.pushState。改到一半突然意识到一个问题——我们花了大量代码在做浏览器本该帮我们做的事情:拦截导航、恢复滚动位置、处理用户点击后退按钮时的确认弹窗。

这些东西,History API 一个都不管。

直到认真看了 Navigation API 的规范和 Chrome 的实现,才发现这套新 API 的设计思路完全不同。它不是在 History API 上打补丁,而是从头定义了"浏览器导航"在 Web 应用里的运作方式。

History API 到底哪里不行

popstate 的信息黑洞

popstate 事件是我们拦截用户前进/后退的唯一手段,但它给出的信息少得可怜:

window.addEventListener('popstate', (event) => {
  // event.state → 之前 pushState 塞进去的数据(如果有的话)
  // 仅此而已。

  // 用户是点了"后退"还是"前进"?不知道
  // 要去的 URL 是什么?得自己读 location.href,但这时候 URL 已经变了
  // 能不能取消这次导航?不能
  // 导航是跨域的还是同源的?不知道
})

注意那个关键问题:URL 已经变了。当 popstate 触发的时候,浏览器地址栏已经更新完毕。想弹个"确认离开?"的对话框?来不及了,地址栏显示的已经是目标页面的 URL。

所有 SPA 路由库处理这种情况的方式都很 hack——先让导航发生,用户如果取消,再偷偷 history.go(1)history.go(-1) 跳回去。用户会看到地址栏闪一下。体验很糟。

pushState 和 replaceState 没有事件

这是另一个让人头疼的设计缺陷。pushStatereplaceState 调用的时候,不会触发任何事件

你自己的代码调 pushState 没问题,因为你知道自己在做什么。但如果页面嵌了第三方脚本,或者微前端场景下子应用自己调了 pushState,主框架对此完全无感知。Vue Router、React Router 这些库的解决办法是 monkey-patch:

const originalPushState = history.pushState.bind(history)
history.pushState = function (...args) {
  originalPushState(...args)
  window.dispatchEvent(new Event('pushstate'))
}

全局 monkey-patch 浏览器原生 API,在微前端场景下多个框架抢着 patch 同一个方法——一旦出问题,排查难度极高。

滚动恢复的半成品

浏览器有个 history.scrollRestoration 属性,设成 'manual' 可以自己管理滚动位置。但"自己管理"意味着什么?你得在离开页面前手动记录滚动位置,把它存到 sessionStorage(因为 history.state 有大小限制),在导航完成后、DOM 渲染完成后、图片加载完成后恢复滚动。对,图片加载完成后——因为懒加载图片会撑开页面高度,恢复时机太早的话位置就不对。你还得区分"新页面导航"和"后退到旧页面"这两种场景。

Vue Router 的 scrollBehavior 和 React Router 的 ScrollRestoration 组件都在做这件事,每个框架自己实现一遍,每个实现都有各自的边缘 case。

NavigateEvent:路由库最想要的那个 API

拦截一切导航

navigate 事件是整个 API 的核心。

navigation.addEventListener('navigate', (event) => {
  // event.navigationType → 'push' | 'replace' | 'reload' | 'traverse'
  // event.destination.url → 目标 URL(导航还没发生)
  // event.canIntercept → 是否可以拦截(跨域导航不行)
  // event.userInitiated → 是不是用户主动触发的

  const url = new URL(event.destination.url)

  if (url.pathname.startsWith('/app')) {
    event.intercept({
      async handler() {
        const content = await fetchPageContent(url.pathname)
        document.querySelector('#app').innerHTML = content
      }
    })
  }
})

event.intercept() 做的事情相当于告诉浏览器:"这个导航我接管了,别真的去加载新页面,URL 你帮我更新就行。"这就是 SPA 路由的本质——以前得通过 preventDefault + pushState + 手动更新视图三步走,现在一个 intercept 搞定。

导航守卫的原生方案

前面提到的"用户点后退时弹确认框"这个场景,是 B 端系统的高频需求。先看我们项目中 Vue Router 的现有写法:

// Vue Router 的 beforeRouteLeave 守卫
// B 端表单页的实际代码
onBeforeRouteLeave((to, from, next) => {
  if (!hasUnsavedChanges.value) return next()

  showConfirmDialog('有未保存的修改,确认离开?')
    .then(() => next())
    .catch(() => next(false))
  // next(false) 底层会调用 history.go(delta) 跳回来
  // 用户可以看到地址栏先变成目标 URL,再闪回当前 URL
})

这段代码的核心问题在于 next(false) 的实现机制:Vue Router 无法真正"阻止"浏览器导航,只能在导航已经发生后,用 history.go() 静默跳回。地址栏的闪烁在低端设备上尤为明显,用户会以为页面出了 bug。

Navigation API 给出了干净利落的替代方案:

navigation.addEventListener('navigate', (event) => {
  if (hasUnsavedChanges && event.navigationType === 'traverse') {
    event.preventDefault() // 直接阻止导航,URL 不会变

    showConfirmDialog({
      onConfirm: () => navigation.traverseTo(event.destination.key)
    })
  }
})

event.preventDefault()navigate 事件里终于能正常工作了——它会阻止导航发生,URL 不会改变,不需要再用 history.go(-1) 来"假装没导航过"。两段代码做的是同一件事,但底层机制完全不同:一个是"事后回滚",一个是"事前拦截"。

有个限制需要留意:对于 traverse 类型的导航(前进/后退),只有 userInitiatedtrue 时才能 preventDefault。浏览器不允许页面默默劫持用户的后退操作,这是合理的安全约束。

异步处理与状态追踪

intercepthandler 是异步函数,这带来一个以前不可能实现的能力——在浏览器层面追踪导航状态。看一下 navigation.navigate() 的返回值就明白了:

const result = navigation.navigate('/dashboard')

// committed: URL 已更新,但 handler 可能还在执行
await result.committed
console.log('URL 已切换,页面正在加载...')

// finished: handler 执行完毕,页面完全就绪
await result.finished
console.log('导航彻底完成')

committedfinished 这两个 Promise 的分离设计相当精妙。在 History API 的世界里,pushState 是同步的、瞬间完成的,你没有任何原生手段知道"页面是否加载完了"。每个路由库都得自己实现 router.isReady()router.afterEach() 这类机制,Navigation API 在浏览器层面直接提供了这个能力,路由库可以基于它简化大量内部代码。

边界情况与迁移风险

浏览器兼容性现状

这是做技术选型时绕不开的问题。截至目前,Navigation API 的浏览器支持范围仍然有限:Chrome 105+(2022 年 8 月发布)和 Edge 105+ 已完整支持全部特性,但 Firefox 和 Safari 仍未实现

这意味着:

  • 如果你的产品面向企业内部(B 端系统、管理后台),且能限定 Chromium 内核浏览器,Navigation API 已经可以投入生产
  • 如果需要覆盖 C 端用户的多浏览器环境,目前不能将 Navigation API 作为唯一路由方案,必须保留 History API 作为 fallback 或继续使用现有路由框架
  • 可以通过 'navigation' in window 做特性检测,在支持的浏览器上启用增强体验,不支持时回退到传统方案

这也是我们 B 端项目最终选择"等 Vue Router 适配后再升级"而非自行封装的原因之一——即便我们的用户全部使用 Chrome,团队也不想长期维护一套兼容层代码。

不能拦截的导航

有几类导航是 Navigation API 管不了的:跨域导航(canInterceptfalse,只能观察)、window.open() 打开的新窗口、用户在地址栏手动输入 URL、<meta http-equiv="refresh"> 触发的刷新。

这些限制是合理的安全边界,但在方案设计时必须考虑到:你的"离开前保存草稿"逻辑不能只依赖 Navigation API,beforeunload 事件在跨页面导航场景下仍然要作为兜底。

状态管理的大小限制

navigation.navigate(url, { state })state 使用结构化克隆算法,比 history.state 的 JSON 序列化灵活得多——DateRegExpMapSetArrayBuffer 都能存。但仍然有大小限制(各浏览器实现不同),别把整个页面的业务数据塞进去。state 应该只存导航相关的元数据:滚动位置、筛选条件、分页页码。业务数据还是走状态管理库或缓存。

从 History API 到 Navigation API 的架构演进

History API 诞生于 2011 年前后,那时候 SPA 还不是主流,设计目标是"让 Ajax 页面也能更新 URL"——一个相当有限的场景。所以它只给了 pushStatepopstate,够用就行。但前端路由在过去十几年的发展远远超出了这个设计预期,导致每个路由框架都得用各种 hack 弥补 API 的不足:monkey-patch 原生方法、手动管理滚动位置、用 history.go 模拟取消导航。

Navigation API 的设计完全基于 SPA 路由的实际需求:拦截导航要在事前而不是事后,方向判断需要历史栈的可见性,导航过程天然是异步的,滚动恢复的时机应该由开发者控制。它不是在旧 API 上叠功能,而是重新设计了导航的抽象模型。

这种"在 userland 积累足够经验后,把通用模式下沉到平台层"的演进路径,在前端领域反复出现。jQuery 的选择器下沉成了 querySelector,Lodash 的工具函数部分下沉成了 ES6+ 原生方法,各路由库的导航拦截下沉成了 Navigation API。每一次下沉都让应用层代码变得更薄,同时也让不同框架之间的行为更一致。

下面这张表是我给团队做技术选型时整理的,比较直观地展示了两代 API 的差异:

能力 History API Navigation API
拦截导航 popstate(事后通知) navigate(事前拦截)
取消导航 不支持原生取消,需 hack preventDefault() 直接取消
导航方向 无法判断 destination.index 对比
历史栈访问 history.length entries() 完整列表
异步导航 不支持 intercept({ handler })
导航完成追踪 无原生机制 committed / finished Promise
滚动恢复 scrollRestoration: manual + 自行实现 scroll: 'after-transition' / event.scroll()
状态存储 JSON 序列化 结构化克隆(支持 Date/Map/Set 等)
View Transitions 集成 手动协调,方向判断困难 handler 内自然集成,方向可知
浏览器支持 所有现代浏览器 Chrome/Edge 105+,Firefox/Safari 未支持

回到我们最初那个 B 端管理系统的路由重构。目前的方案是先不动 Vue Router,等它的下个大版本在底层切换到 Navigation API 后平滑升级。但这次调研让团队对路由的底层机制有了更清晰的认知——以前 debug 滚动恢复不准或者后退拦截闪烁的问题,总觉得是路由库的 bug,现在才明白是 History API 本身的局限。如果你在起一个新项目,目标浏览器都是 Chromium 内核,又不想引入重型路由框架,直接用 Navigation API 搭路由层完全可行。如果是存量项目,等框架适配是风险最低的迁移路径。

深入浅出 React Hooks 原理:从 Fiber 的 memoizedState 链表讲到 updateQueue 调度

作者 AlkaidSTART
2026年3月31日 10:41

这篇文章想解决 3 个常见问题:

  • 为什么 Hook 不能写在 if 里?
  • 为什么依赖数组漏了一个值就会出现“旧状态/旧闭包”?
  • 为什么 setState 之后立刻拿到的还是旧值?

下面我们换个更直观的角度:

  • Hooks 的数据到底“放哪儿了”?
  • setState 之后为什么不会立刻变?
  • 依赖数组漏写为什么会拿到“旧闭包”?

把这三点串起来,你就能把上面 3 个问题一次性想明白。

1. “副作用”到底是什么?(为什么需要 useEffect)

React 是基于 UI 驱动的前端框架,UI 是 props/state 的函数结果。理想情况下,渲染应该是纯函数:输入确定,输出也确定,不依赖和修改任何外部变量,这叫“没有副作用”。

但实际开发中经常要做这些事情:数据请求、定时器、DOM 操作等。这些都依赖或改变“组件外部”的世界,所以被称为副作用。

2. Hooks 的引出:函数组件怎么拥有状态与副作用能力

为了让函数组件也能拥有“状态”和“副作用处理能力”,React 引入 Hooks。

比如 useStateuseEffect 等 API,让函数组件也能维护内部状态,并在合适的时机处理请求、订阅、DOM 操作等副作用。

3. Hook 的数据存在哪?

3.1 Fiber 节点:函数组件的“档案袋”

在 React 内部,你可以把 Fiber 节点理解成“这个组件实例的身份证”:组件每在页面里出现一次,就会有一个对应的 Fiber。

组件所有的 Hooks 数据(state、ref、effect 的一些信息等),都会跟着这个 Fiber 走,最关键的入口就是 memoizedState

另外,Fiber 也是 React 用来做调度和更新的基本单位。跟我们这篇文章关系最大的几个字段就是:memoizedPropsmemoizedStateupdateQueue

顺带说一句虚拟 DOM:它本质就是用普通 JS 对象描述“界面长什么样”,React 会在合适的时机把这些变化真正同步到浏览器的 DOM 上。

1.png

比如下面这一段代码,展示 DOM 和虚拟 DOM 之间的对应关系:

<div id="app" class="container">
  <p>hello</p>
</div>
{
  type: 'div',
  props: {
    id: 'app',
    className: 'container'
  },
  children: [
    { type: 'p', children: 'hello' }
  ]
}

3.2 memoizedState 是什么?它为什么是“链表”

memoizedState 本身是一个链表头。链表中按固定顺序存储所有 Hook 的状态:包括 useState 的值、useReducer 的 state、useRefuseEffect 的依赖等。

例如:

function Demo() {
  const [count, setCount] = useState(0)
  const [text, setText] = useState('abc')
  return <div>{count} {text}</div>
}

React 内部会为每个 Hook 创建一个 hook 对象

const hook1 = {
  memoizedState: 0,  // count 的值
  next: hook2       // 指向第二个 hook
}

const hook2 = {
  memoizedState: 'abc',// 第二个 useState 的缓存值
  next: null           // 链表尾部
}

然后把第一个 hook 挂载到 Fiber 节点:

fiberNode.memoizedState = hook1

最终形成:

fiberNode.memoizedState = {
  memoizedState: 0,
  next: {
    memoizedState: 'abc',
    next: null
  }
}

所以:fiberNode.memoizedState 就是 Hook 链表的头结点

3.3 为什么 Hooks 不能在 if / for / 嵌套函数里使用?

因为 React 是按顺序,从 memoizedState 链表从头往后依次取 Hook 的:

  • 第 1 次调用 useState → 取链表第 1 个 hook
  • 第 2 次调用 useState → 取链表第 2 个 hook
  • 第 3 次调用 useEffect → 取链表第 3 个 hook

如果在 if 里写 Hook:

if (someCondition) {
  const [count, setCount] = useState(0)
}
const [text, setText] = useState('abc')
  • 第一次渲染:条件成立 → 链表顺序是 useState → useState
  • 第二次渲染:条件不成立 → 少执行一个 Hook,后面的 Hook 取到的“位置”就错了

**链表顺序对不上,状态就会串位。**所以 React 强制要求:所有 Hooks 必须在组件顶层,并按固定顺序执行。

4. updateQueue:更新任务先存哪?

为什么 setState 之后立刻读到的还是旧值呢?

核心点是:setState 不是“立即改 memoizedState”,而是先把更新放进队列,等 React 调度到这一轮渲染时再统一计算。

4.1 updateQueue 介绍:

组件最终生效的状态会存在 memoizedState 里。

那在“状态更新还没真正计算、还没生效之前”,这些更新任务存在哪?答案就是:updateQueue

它的作用是:收集所有尚未处理的 state 更新,形成一个更新队列

例如:

const [count, setCount] = useState(0)

setCount(1)
setCount(2)
setCount(3)

连续调用三次 setCount 并不会立即修改 memoizedState

React 会先创建 3 个更新对象,放进 updateQueue 排队:

fiber.updateQueue = {
  pending: {
    action: 1,
    next: {
      action: 2,
      next: {
        action: 3,
        next: ... // 环形链表,最后指回第一个
      }
    }
  }
}

4.2 updateQueue 如何把更新跑起来:

updateQueue 自己不会“变更界面”,它更像一个盒子:先把这次要改什么记下来。

当把更新对象加入 updateQueue 时:

  1. React 会把这个 fiber 标记为“有更新”。
  2. 根据更新优先级发起调度。
  3. 调度开始后进入 render 阶段:遍历 Fiber 树,从 updateQueue 取出更新,计算出最终新 state,保存到 memoizedState。
  4. React 会对比前后两次“界面应该长什么样”,算出需要改动的那一小部分。
  5. commit 阶段修改 DOM,最终更新视图。

3.png

4.3 为什么 setState 后立刻打印还是旧值?

因为你“调用 setState 的那一刻”,更新还只是进入了 updateQueue。

真正写入 memoizedState 并触发重新渲染,要等 React 在后续调度中跑完 render/commit。

另外,如果你的新状态依赖旧状态,建议用函数式更新,避免读到旧值:

setCount(c => c + 1)

5. 为什么依赖数组漏值会出现“旧状态/旧闭包”(解决:依赖数组问题)

关键点是:每次渲染都会产生一个新的闭包

useEffect 如果依赖数组是空的,它只会在首次渲染时记录那次渲染产生的回调函数,后面的渲染不会替换掉它,于是回调里拿到的 state/props 就会“停留在第一次”。

例如:

useEffect(() => {
  console.log(count)
}, [])

常见写法大概两种:

不写依赖数组(每次渲染后都会执行一次):

useEffect(() => {
  // 组件初始化 + 每次更新都会执行
})

写依赖数组(依赖变了才执行):

useEffect(() => {
  // count 变化时才执行
}, [count])

到这里,这几个常见疑问其实都能落到同一个答案:

  • React 取 Hooks 状态,靠的是 Fiber 上那条 按顺序排好的 Hook 链表
  • 你调用 setState 的时候,React 先把“要怎么改”塞进 updateQueue,等这一轮调度真正跑起来再统一算。
  • effect 回调里拿到的变量,本质上来自“某一次渲染生成的闭包”,依赖数组写漏了,就可能一直用的是旧那次。

把这三点抓住,后面的学习才会行稳致远

canves实现画布

作者 取名不易
2026年3月31日 10:37
<template>
  <div class="cad-container">
    <!-- 顶部工具栏 -->
    <header class="cad-header">
      <div class="brand">
        <h1>Nuxt CAD Pro <span class="version">v2.1 Fix</span></h1>
      </div>
      <div class="toolbar">
        <!-- 工具选择 -->
        <div class="tool-group">
          <button
            v-for="t in tools"
            :key="t.value"
            :class="['tool-btn', { active: currentTool === t.value }]"
            @click="currentTool = t.value"
          >
            {{ t.label }}
          </button>
        </div>

        <!-- 绘图属性 -->
        <div class="tool-group">
          <label>颜色:</label>
          <input type="color" v-model="defaultStroke" />
          <label>填充:</label>
          <input type="color" v-model="defaultFill" />
          <label class="checkbox-label">
            <input type="checkbox" v-model="defaultEnableFill" />
            启用
          </label>
          <label>线宽:</label>
          <select v-model="defaultLineWidth">
            <option :value="1">细 (1px)</option>
            <option :value="3">中 (3px)</option>
            <option :value="5">粗 (5px)</option>
          </select>
        </div>

        <!-- 全局操作 -->
        <div class="tool-group">
          <button @click="undo" :disabled="history.length === 0">
            ↩️ 撤销
          </button>
          <button @click="clear" class="danger">🗑️ 清空</button>
          <button @click="save" class="primary">💾 导出</button>
        </div>
      </div>
    </header>

    <div class="workspace">
      <!-- 左侧画布 -->
      <main class="canvas-wrapper">
        <canvas
          ref="canvas"
          @mousedown="onMouseDown"
          @mousemove="onMouseMove"
          @mouseup="onMouseUp"
          @mouseleave="onMouseUp"
        ></canvas>
        <div class="coordinates">
          X: {{ Math.round(cursorX) }} Y: {{ Math.round(cursorY) }}
        </div>
      </main>

      <!-- 右侧属性面板 -->
      <aside class="properties-panel" v-if="selectedShape">
        <h3>属性面板</h3>
        <div class="prop-item">
          <label>类型:</label>
          <span>{{ getTypeName(selectedShape.type) }}</span>
        </div>
        <div class="prop-item">
          <label>位置 X:</label>
          <input
            type="number"
            v-model.number="selectedShape.x"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div class="prop-item">
          <label>位置 Y:</label>
          <input
            type="number"
            v-model.number="selectedShape.y"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div
          class="prop-item"
          v-if="
            selectedShape.type !== 'line' && selectedShape.type !== 'pencil'
          "
        >
          <label>宽度 W:</label>
          <input
            type="number"
            v-model.number="selectedShape.w"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div
          class="prop-item"
          v-if="
            selectedShape.type !== 'line' && selectedShape.type !== 'pencil'
          "
        >
          <label>高度 H:</label>
          <input
            type="number"
            v-model.number="selectedShape.h"
            @change="updateShapeAndRedraw"
          />
        </div>

        <div class="prop-item">
          <label>描边:</label>
          <input
            type="color"
            v-model="selectedShape.stroke"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div
          class="prop-item"
          v-if="['rect', 'circle'].includes(selectedShape.type)"
        >
          <label>填充:</label>
          <input
            type="color"
            v-model="selectedShape.fill"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div
          class="prop-item"
          v-if="['rect', 'circle'].includes(selectedShape.type)"
        >
          <label>启用填充:</label>
          <input
            type="checkbox"
            v-model="selectedShape.enableFill"
            @change="updateShapeAndRedraw"
          />
        </div>
        <div class="prop-item">
          <label>线宽:</label>
          <input
            type="number"
            v-model.number="selectedShape.lineWidth"
            @change="updateShapeAndRedraw"
          />
        </div>

        <button @click="deleteSelected" class="danger full-width">
          删除对象
        </button>
      </aside>
    </div>
  </div>
</template>

<script lang="ts">
import { Vue, Component } from "nuxt-property-decorator";

type ToolType = "select" | "line" | "rect" | "circle" | "pencil";
type ShapeType = "line" | "rect" | "circle" | "pencil";

interface Point {
  x: number;
  y: number;
}

interface Shape {
  id: number;
  type: ShapeType;
  x: number;
  y: number;
  w: number;
  h: number;
  points?: Point[];
  stroke: string;
  fill: string;
  enableFill: boolean;
  lineWidth: number;
}

@Component
export default class CadSketcher extends Vue {
  currentTool: ToolType = "select";

  defaultStroke = "#00ff00";
  defaultFill = "#ff0000";
  defaultEnableFill = false;
  defaultLineWidth = 2;

  shapes: Shape[] = [];
  history: Shape[][] = [];
  selectedShapeId: number | null = null;

  isDrawing = false;
  isDragging = false;
  isResizing = false;
  startPos: Point = { x: 0, y: 0 };
  cursorX = 0;
  cursorY = 0;

  tools = [
    { label: "🖱️ 选择", value: "select" },
    { label: "✏️ 铅笔", value: "pencil" },
    { label: "📏 直线", value: "line" },
    { label: "⬜ 矩形", value: "rect" },
    { label: "⭕ 圆形", value: "circle" },
  ];

  get selectedShape(): Shape | undefined {
    return this.shapes.find((s) => s.id === this.selectedShapeId);
  }
  get canvasEl(): HTMLCanvasElement {
    return this.$refs.canvas as HTMLCanvasElement;
  }
  get ctx(): CanvasRenderingContext2D {
    return this.canvasEl.getContext("2d")!;
  }

  mounted() {
    this.resizeCanvas();
    this.drawGrid();
    window.addEventListener("resize", this.resizeCanvas);
    document.addEventListener("keydown", (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === "z") {
        this.undo();
      }
      if (e.key === "Delete") {
        this.deleteSelected();
      }
    });
  }

  resizeCanvas() {
    const parent = this.canvasEl.parentElement;
    if (parent) {
      this.canvasEl.width = parent.clientWidth;
      this.canvasEl.height = parent.clientHeight;
      this.redraw();
    }
  }

  redraw() {
    // 1. 清空画布
    this.ctx.fillStyle = "#1e1e1e";
    this.ctx.fillRect(0, 0, this.canvasEl.width, this.canvasEl.height);
    this.drawGrid();

    // 2. 绘制所有已保存的图形
    this.shapes.forEach((shape) => this.drawSingleShape(shape));

    // 3. 绘制选中框
    if (this.selectedShapeId) {
      const shape = this.shapes.find((s) => s.id === this.selectedShapeId);
      if (shape) this.drawSelectionBox(shape);
    }
  }

  drawGrid() {
    const { width, height } = this.canvasEl;
    this.ctx.strokeStyle = "#2a2a2a";
    this.ctx.lineWidth = 1;
    this.ctx.beginPath();
    for (let x = 0; x <= width; x += 20) {
      this.ctx.moveTo(x, 0);
      this.ctx.lineTo(x, height);
    }
    for (let y = 0; y <= height; y += 20) {
      this.ctx.moveTo(0, y);
      this.ctx.lineTo(width, y);
    }
    this.ctx.stroke();
  }

  drawSingleShape(shape: Shape) {
    this.ctx.beginPath();
    this.ctx.strokeStyle = shape.stroke;
    this.ctx.lineWidth = shape.lineWidth;
    this.ctx.lineCap = "round";
    this.ctx.lineJoin = "round";
    this.ctx.fillStyle = shape.fill;

    if (shape.type === "line") {
      this.ctx.moveTo(shape.x, shape.y);
      this.ctx.lineTo(shape.x + shape.w, shape.y + shape.h);
      this.ctx.stroke();
    } else if (shape.type === "rect") {
      this.ctx.rect(shape.x, shape.y, shape.w, shape.h);
      if (shape.enableFill) this.ctx.fill();
      this.ctx.stroke();
    } else if (shape.type === "circle") {
      const radius = Math.sqrt(shape.w * shape.w + shape.h * shape.h);
      this.ctx.arc(shape.x, shape.y, radius, 0, 2 * Math.PI);
      if (shape.enableFill) this.ctx.fill();
      this.ctx.stroke();
    } else if (shape.type === "pencil" && shape.points) {
      this.ctx.moveTo(shape.points[0].x, shape.points[0].y);
      for (let i = 1; i < shape.points.length; i++) {
        this.ctx.lineTo(shape.points[i].x, shape.points[i].y);
      }
      this.ctx.stroke();
    }
  }

  drawSelectionBox(shape: Shape) {
    const bounds = this.getShapeBounds(shape);
    this.ctx.strokeStyle = "#007acc";
    this.ctx.lineWidth = 1;
    this.ctx.setLineDash([5, 5]);
    this.ctx.strokeRect(
      bounds.x - 5,
      bounds.y - 5,
      bounds.w + 10,
      bounds.h + 10
    );
    this.ctx.setLineDash([]);

    this.ctx.fillStyle = "#fff";
    this.ctx.fillRect(bounds.x + bounds.w - 4, bounds.y + bounds.h - 4, 8, 8);
    this.ctx.strokeRect(bounds.x + bounds.w - 4, bounds.y + bounds.h - 4, 8, 8);
  }

  getShapeBounds(shape: Shape) {
    if (shape.type === "circle") {
      const r = Math.sqrt(shape.w * shape.w + shape.h * shape.h);
      return { x: shape.x - r, y: shape.y - r, w: r * 2, h: r * 2 };
    }
    if (shape.type === "line") {
      return {
        x: Math.min(shape.x, shape.x + shape.w),
        y: Math.min(shape.y, shape.y + shape.h),
        w: Math.abs(shape.w),
        h: Math.abs(shape.h),
      };
    }
    return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
  }

  getMousePos(e: MouseEvent): Point {
    const rect = this.canvasEl.getBoundingClientRect();
    return { x: e.clientX - rect.left, y: e.clientY - rect.top };
  }

  // --- 核心修复:鼠标按下 ---
  onMouseDown(e: MouseEvent) {
    const pos = this.getMousePos(e);
    this.startPos = pos;
    this.cursorX = pos.x;
    this.cursorY = pos.y;

    // 1. 选择工具逻辑
    if (this.currentTool === "select") {
      if (this.selectedShapeId) {
        const shape = this.shapes.find((s) => s.id === this.selectedShapeId);
        if (shape) {
          const bounds = this.getShapeBounds(shape);
          const handleX = bounds.x + bounds.w;
          const handleY = bounds.y + bounds.h;
          if (
            Math.abs(pos.x - handleX) < 10 &&
            Math.abs(pos.y - handleY) < 10
          ) {
            this.isResizing = true;
            return;
          }
        }
      }

      const clickedShape = this.shapes
        .slice()
        .reverse()
        .find((s) => this.isHitTest(s, pos));
      if (clickedShape) {
        this.selectedShapeId = clickedShape.id;
        this.isDragging = true;
        this.redraw();
        return;
      } else {
        this.selectedShapeId = null;
        this.redraw();
      }
    }

    // 2. 绘图工具逻辑
    if (["line", "rect", "circle", "pencil"].includes(this.currentTool)) {
      this.isDrawing = true;
      this.saveHistory(); // 保存历史是为了撤销操作

      // 铅笔工具比较特殊,需要立即创建对象以便实时绘制点
      if (this.currentTool === "pencil") {
        const newShape: Shape = {
          id: Date.now(),
          type: "pencil",
          x: 0,
          y: 0,
          w: 0,
          h: 0,
          points: [pos],
          stroke: this.defaultStroke,
          fill: this.defaultFill,
          enableFill: false,
          lineWidth: this.defaultLineWidth,
        };
        this.shapes.push(newShape);
      }
    }
  }

  // --- 核心修复:鼠标移动 ---
  onMouseMove(e: MouseEvent) {
    const pos = this.getMousePos(e);
    this.cursorX = pos.x;
    this.cursorY = pos.y;

    // 拖拽逻辑
    if (this.isDragging && this.selectedShapeId) {
      const shape = this.shapes.find((s) => s.id === this.selectedShapeId);
      if (shape) {
        const dx = pos.x - this.startPos.x;
        const dy = pos.y - this.startPos.y;
        shape.x += dx;
        shape.y += dy;
        if (shape.type === "pencil" && shape.points) {
          shape.points.forEach((p) => {
            p.x += dx;
            p.y += dy;
          });
        }
        this.startPos = pos;
        this.redraw();
      }
      return;
    }

    // 缩放逻辑
    if (this.isResizing && this.selectedShapeId) {
      const shape = this.shapes.find((s) => s.id === this.selectedShapeId);
      if (shape) {
        shape.w = pos.x - shape.x;
        shape.h = pos.y - shape.y;
        this.redraw();
      }
      return;
    }

    // 绘图预览逻辑
    if (this.isDrawing) {
      // 铅笔是实时添加点到数组,所以不需要重绘整个场景,只需要重绘最后一笔
      if (this.currentTool === "pencil") {
        const shape = this.shapes[this.shapes.length - 1];
        if (shape && shape.points) shape.points.push(pos);
        this.redraw();
      } else {
        // 其他工具(线、矩形、圆):
        // 1. 先重绘背景(清除上一帧的预览)
        this.redraw();
        // 2. 再绘制当前的“鬼影”图形
        const tempShape: Shape = {
          id: 0,
          type: this.currentTool as ShapeType,
          x: this.startPos.x,
          y: this.startPos.y,
          w: pos.x - this.startPos.x,
          h: pos.y - this.startPos.y,
          stroke: this.defaultStroke,
          fill: this.defaultFill,
          enableFill: this.defaultEnableFill,
          lineWidth: this.defaultLineWidth,
        };
        this.drawSingleShape(tempShape);
      }
    }
  }

  // --- 核心修复:鼠标抬起 (修复图形消失的关键) ---
  onMouseUp() {
    // 如果正在绘图且不是铅笔(铅笔已经在 MouseMove 中处理了)
    if (
      this.isDrawing &&
      ["line", "rect", "circle"].includes(this.currentTool)
    ) {
      // 1. 创建正式的形状对象
      const newShape: Shape = {
        id: Date.now(),
        type: this.currentTool as ShapeType,
        x: this.startPos.x,
        y: this.startPos.y,
        w: this.cursorX - this.startPos.x,
        h: this.cursorY - this.startPos.y,
        stroke: this.defaultStroke,
        fill: this.defaultFill,
        enableFill: this.defaultEnableFill,
        lineWidth: this.defaultLineWidth,
      };

      // 2. 将形状推入数组(这才是“固化”图形的关键)
      this.shapes.push(newShape);

      // 3. 自动选中新画的图形
      this.selectedShapeId = newShape.id;
    }

    // 重置状态
    this.isDrawing = false;
    this.isDragging = false;
    this.isResizing = false;

    // 4. 最终重绘一次,确保所有状态正确
    this.redraw();
  }

  isHitTest(shape: Shape, pos: Point): boolean {
    const padding = shape.lineWidth + 5;
    const bounds = this.getShapeBounds(shape);
    return (
      pos.x >= bounds.x - padding &&
      pos.x <= bounds.x + bounds.w + padding &&
      pos.y >= bounds.y - padding &&
      pos.y <= bounds.y + bounds.h + padding
    );
  }

  saveHistory() {
    this.history.push(JSON.parse(JSON.stringify(this.shapes)));
    if (this.history.length > 20) this.history.shift();
  }

  undo() {
    if (this.history.length > 0) {
      this.shapes = this.history.pop()!;
      this.selectedShapeId = null;
      this.redraw();
    }
  }

  deleteSelected() {
    if (this.selectedShapeId) {
      this.saveHistory();
      this.shapes = this.shapes.filter((s) => s.id !== this.selectedShapeId);
      this.selectedShapeId = null;
      this.redraw();
    }
  }

  updateShapeAndRedraw() {
    this.redraw();
  }

  clear() {
    this.saveHistory();
    this.shapes = [];
    this.selectedShapeId = null;
    this.redraw();
  }

  save() {
    const link = document.createElement("a");
    link.download = `cad-sketch-${Date.now()}.png`;
    link.href = this.canvasEl.toDataURL();
    link.click();
  }

  getTypeName(type: string) {
    const map: any = {
      line: "直线",
      rect: "矩形",
      circle: "圆形",
      pencil: "铅笔",
    };
    return map[type] || type;
  }
}
</script>

<style scoped>
.cad-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background: #121212;
  color: #e0e0e0;
  font-family: sans-serif;
  overflow: hidden;
}
.cad-header {
  background: #252526;
  padding: 10px 20px;
  border-bottom: 1px solid #333;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.version {
  font-size: 0.6em;
  color: #888;
  margin-left: 10px;
}
.toolbar {
  display: flex;
  gap: 20px;
  align-items: center;
  flex-wrap: wrap;
}
.tool-group {
  display: flex;
  align-items: center;
  gap: 8px;
  padding-right: 15px;
  border-right: 1px solid #3e3e42;
}
.tool-group:last-child {
  border: none;
  margin-left: auto;
}
.tool-btn {
  background: #333;
  border: 1px solid #444;
  color: #ccc;
  padding: 6px 12px;
  cursor: pointer;
  border-radius: 3px;
}
.tool-btn.active {
  background: #007acc;
  color: white;
  border-color: #007acc;
}
button.primary {
  background: #0e639c;
  color: white;
  border: 1px solid #0e639c;
}
button.danger {
  background: #a13030;
  color: white;
  border: 1px solid #a13030;
}
button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
input[type="color"] {
  border: none;
  width: 24px;
  height: 24px;
  background: none;
  cursor: pointer;
}
select {
  background: #333;
  color: white;
  border: 1px solid #444;
  padding: 4px;
  border-radius: 3px;
}
.checkbox-label {
  display: flex;
  align-items: center;
  gap: 5px;
  font-size: 0.85rem;
  cursor: pointer;
}
.workspace {
  display: flex;
  flex: 1;
  overflow: hidden;
}
.canvas-wrapper {
  flex: 1;
  position: relative;
  background: #121212;
  overflow: hidden;
}
canvas {
  display: block;
  cursor: crosshair;
}
.coordinates {
  position: absolute;
  bottom: 10px;
  right: 15px;
  background: rgba(0, 0, 0, 0.7);
  padding: 4px 8px;
  border-radius: 4px;
  font-family: monospace;
  font-size: 0.8rem;
  color: #00ff00;
}
.properties-panel {
  width: 220px;
  background: #252526;
  border-left: 1px solid #333;
  padding: 15px;
  overflow-y: auto;
}
.properties-panel h3 {
  margin: 0 0 15px 0;
  font-size: 1rem;
  color: #007acc;
}
.prop-item {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}
.prop-item label {
  font-size: 0.85rem;
  color: #ccc;
  min-width: 60px;
}
.prop-item input[type="number"] {
  background: #333;
  border: 1px solid #444;
  color: white;
  padding: 4px 8px;
  border-radius: 3px;
  width: 100%;
}
.full-width {
  width: 100%;
  margin-top: 20px;
}
</style>

企业微信截图_1774924495650.png

前端资质越高,越来越不敢随便升级框架?

作者 ErpanOmer
2026年3月31日 10:29

上个星期五下午,临近下班,组里一个刚入职不久、技术热情极高的小伙子,给我提了个极具分量的 PR。

他跑到我工位旁,眼里闪着光:老大,我把咱们那个核心中后台项目的 React 从 17 直接升到 19 了,顺便把 Webpack 换成了 Rsbuildrelease note 说性能提升了将近 40%,我本地跑了一下,秒开!

看着他求表扬的神情,我的心却瞬间沉到了谷底。

我点开 package.json 的 diff,好家伙,红绿相间的变动多达七十多处。除了 React 自身的跨代大版本,连带着状态管理、路由、甚至底下好几个用来处理复杂 Excel 导出的老旧插件,全被强行 npm update 到了最新版。

65f2e014-3840-4833-8131-25faeed66fb9.png

我深吸了一口气,默默把他的 PR 关了,并告诉他:本地跑通不算通。这个合并如果今天发到线上,明晚咱们整个组大概率都要在 P0 故障复盘会上做检讨。😖

很多年轻前端可能觉得我太保守,甚至有点老顽固。 技术社区里天天都在吹 Vue 3.x 又出了什么革命性宏,React 19 的 Server Components 有多颠覆,Vite 又把构建速度压缩了多少毫秒等等。

在他们眼里,升级框架就像给手机系统点一下更新那么简单,不仅能提效,写进简历里还能多一句---主导项目底层技术栈升级。

但在前端领域摸爬滚打了很多年、背过无数次线上事故的锅之后,我慢慢明白了一个极其骨感的现实告诉你:前端资质越高,越不敢随便升级框架。


升级其实没那么简单

年轻时总有一种错觉,以为升级框架就是改一下 package.json 里的数字,然后顺着终端里的 warning 把废弃的 API 替换掉就完事了。

但真实的业务工程,是一个由无数个三方库、内部包、魔改组件和历史妥协交织而成的巨大复杂度。

就拿上面那个小伙子强升 React 19 来说。他只看到了 React 19 把 forwardRef 废弃了,直接把 ref 当 prop 传就行,代码看起来确实优雅了。

但他没看到的是,咱们项目里深埋着一个 4 年前离职前辈写的、极其复杂的虚拟滚动表格组件。当年为了在老版本里拿到底层 DOM,前辈用了极其 Hack 的方式去代理 ref

// 离职前辈留下的祖传代码
// 强行拦截并劫持了内部的 ref 实例,用来做复杂的聚焦与按键接管
const ComplexTable = React.forwardRef((props, ref) => {
  const internalRef = useRef();
  
  useImperativeHandle(ref, () => ({
    forceScroll: (y) => {
      // 依赖了早期版本某 UI 库极其脆弱的内部结构
      internalRef.current.querySelector('.ant-table-body').scrollTop = y;
    },
    // ... 其他 20 个不知道哪里会调用的这些方法
  }));

  return <Table ref={internalRef} {...props} />;
});

当你兴冲冲地把大版本一升,底层的渲染时机变了,或者旧版 UI 库的 DOM 结构因为不兼容而错位了。本地跑的时候,页面确实渲染出来了。

但等到下周一,财务部门的同事在处理每个月一万条数据的工资单,习惯性地按下回车键想要让表格自动滚动到下一行时——整个页面直接白屏崩溃😢。

这种深水区的依赖断裂,TypeScript 查不出来,E2E 测试如果没有覆盖到这一步也抓不到。最后买单的,就是签字同意合入代码的技术负责人。

牵一发而动全身。在没有 100% 自动化测试覆盖率的团队里,升级底层框架不叫重构,那叫排雷🤷‍♂️。


技术自嗨?

很多程序员在谈论框架升级时,往往会陷入一种技术自嗨的盲区。

你看,新的打包工具让首屏渲染快了 0.5 秒!

确实很棒。但然后呢?

站在业务视角的逻辑是极其冰冷的:如果一个系统目前运行稳定,没有遇到致命的性能瓶颈,也没有阻碍新需求的迭代,那么动它的底层,就是典型的 ROI(投资回报率)倒挂。

你花了两周时间解决各种依赖冲突,把 Vue 2 强行拔高到了 Vue 3,把 Options API 费劲巴拉地重构成了 Composition API。

如果升级成功了呢, 业务侧毫无感知,产品经理不会多给你发一毛钱奖金,老板甚至觉得你这两周业务产出为零😖。

升级失败了(或者带入了暗病):阻断了核心业务流程,造成客户流失,你就是那个没事找事、搞出 P0 事故的千古罪人。

这不是叫你摆烂,而是工程学里一条极其重要的铁律:If it ain't broke, don't fix it.(只要没坏,就别瞎修。)

我们写代码是为了解决业务问题,而不是为了满足自己对时髦技术的热爱。把屎山代码翻新成现代化技术栈,它依旧可能是一座逻辑混乱的屎山,只不过现在是一座编译速度更快的屎山罢了😒。


什么时候才该升?

那难道我们就永远守着老旧的版本,在历史包袱里等死吗? 当然不是。

高级前端和初级前端的区别就在于:初级前端为了新而升级,高级前端为了解决痛点而升级。

遇到以下三种情况,哪怕风险再大,我也一定带着团队往上升:

如果触及了不可逾越的性能天花板。 比如旧版框架的 Virtual DOM 算法在十万级数据渲染时已经彻底锁死主线程,而新版的并发特性或细粒度更新能从底层解决这个问题。

安全漏洞与 LTS(长期支持)结束。 底层依赖被扫出高危漏洞,且官方不再为老版本提供补丁,如果不升,过不了公司的内部安全红线。

生态彻底断裂。 现有的技术栈已经古老到找不到能兼容的周边库了,新招来的员工看这代码像看甲骨文,维护成本已经远超升级成本。

而且,真正老道的升级,绝不是开个新分支一把梭。 那是细致入微的依赖盘点、是灰度发布、是双栈运行、是哪怕天塌下来也能在 5 分钟内切回老代码的降级预案。


前几天我在社区看到一句话,深以为然:每一个看似极其保守的技术决策背后,都站着一个曾经被线上 Bug 毒打得死去活来的灵魂😁。

所以,当你的组长拒绝你那份华丽的底层升级改造方案时,别急着在心里骂他老土。他不是不懂新技术,他只是比你更懂那条在深夜里突然响起的线上报警短信,有多么让人绝望😒。

对于一线开发者来说,关注前沿技术、保持对框架底层演进的好奇心,绝对是好事。它能保持你的敏锐度,让你在写新项目时拥有更好的技术选型视野。

但在一个沉淀了无数业务逻辑的历史工程面前,请保持谨慎。

真正的技术大佬,不是那个天天在项目里倒腾最新框架的极客。而是那个哪怕手握一大把老旧框架,也能稳稳当当把复杂的业务需求切得明明白白,让系统稳如老狗的架构师。

对此大家怎么看?

下班啦下班啦下班啦下班啦下班啦下班啦1,下班啦下班啦下班啦下班啦.gif

Harness Engineering 前端开发的下一个阶段

2026年3月31日 10:09

最近大家都在聊 OpenAI 提出的 Harness Engineering(驾驭工程)。刚看到这个词时,我以为是又一套 AI 编程方法论,详细了解了一下,发现它说的其实是工作方式的底层转变:

当开发者的主要工作不再是亲手写代码,而是设计环境、明确意图、搭建反馈回路,让智能体可靠完成工作时,工程方式就变了。

对前端来说,天然有一套"可视化验证 + 组件约束 + 自动检查"的体系,只是以前用来约束人,现在可以用来约束 AI。

和普通 AI 辅助开发有什么区别?

  • 普通用法:“我给 AI 一个 prompt,让它帮我写个组件。”
  • Harness Engineering 的思路:“我先把项目整理成 AI 不容易犯错的样子,再让 AI 干活。”

两者的核心区别,是焦点从“提示词”转向了“约束系统”,除了上下文工程,Harness Engineering 更强调架构约束和持续清理代码库的“熵管理”。

那么,作为前端开发者,我们具体可以怎么做?

1. 把设计系统做成让 AI 也看得懂

前端最常见的问题不是“写不出页面”,而是“写出来不一致”。因此,第一步不是让 AI 更自由,而是让它更难犯错。

我们可以这样做:

  • 统一组件入口:例如,通过路径别名强制从 @/components/ui 引用组件。
  • 明确组件 API:为按钮、表单、弹窗等组件建立清晰、稳定的接口。
  • 提供正反示例:在每个组件文档中,写明“允许”和“禁止”的用法。
  • 机器可检查的约束:将设计 token、间距、字号等规则,写成可以被 ESLint 等工具自动检查的代码。
  • 建立“模仿”目录:在项目中创建 patterns/ 或 examples/ 目录,存放标准用法,供 AI 直接学习和模仿。

这样一来,AI 生成页面时,不是在“自由发挥”,而是在“走轨道”。一句话总结就是:先把组件库工程化,再把 AI 接进来。

2. 把“页面正确”,从主观审美变成可执行验证

Harness Engineering 的核心是建立“反馈回路”,要让智能体能读取 DOM、截图、日志和指标,自行验证和修复问题。

在前端,最适合建立反馈回路的方式就是各种自动化测试:

  • Playwright / Cypress:端到端测试,模拟真实用户操作。
  • 视觉回归测试:确保 UI 变更不会产生意外的视觉效果。
  • Storybook + 交互测试:隔离组件,验证其功能和交互。
  • 多层校验体系:ESLint + TypeScript + 单元测试 + E2E 测试,层层把关。

过去,我们可能会对 AI 说:“做一个登录页。”现在,应该把任务变成:“做一个登录页,并且必须满足:

  1. 桌面和移动端布局都正常。
  2. 表单校验错误态完整。
  3. Playwright 的核心用例通过。
  4. Lighthouse 性能评分大于 85。
  5. 视觉回归测试无明显差异。”

这样,AI 才有了清晰的“完成标准”,而不是“生成了一堆看似合理的代码”。

3. 把 PR 审查标准写死,不要靠口头经验

把大量知识和规则放进结构化的文档中,而不是一个臃肿的 AGENTS.md。短的索引文件只是入口,真正的规则体系存放在代码库的文档里。

我们可以可以借鉴Open AI的实践:

  • docs/frontend-architecture.md:项目整体架构。
  • docs/component-guidelines.md:组件设计和使用指南。
  • docs/accessibility-rules.md:可访问性标准。
  • docs/state-management.md:状态管理方案。
  • docs/page-checklist.md:页面开发的检查清单。

这些文档应包含明确的决策和规则,例如:

  • 遇到表单优先用哪个方案?
  • 哪些页面必须用 SSR / SSG / CSR?
  • 网络请求如何统一封装?
  • 错误提示、空状态、loading 怎么处理?

当这些规则被“写死”,无论将来是人还是 AI 参与开发,都将遵循同一套标准,保证产出一致性和高质量。

4. 把“前端调试”变成 AI 可复现的流程

前端 bug 的痛点往往不是不会修,而是难以复现。Harness Engineering 的一个重要思想是:每次 agent 犯错,不只是修这一次,而是补一个机制,让它下次不再这么错。

前端可以这样落地:

  • 为每个 bug 配复现步骤:能录屏就录屏,能保留请求/响应样本就保留。
  • 为典型缺陷补测试:为重现的 bug 补充 E2E 用例,为复杂组件补充 Storybook 场景。
  • 强化监控:为线上异常补充更完善的前端监控和日志埋点。

例如,遇到一个“Modal 打开后页面还能滚动”的 bug。修完代码只是开始。真正的 Harness 做法是:

  1. 补一个能复现该问题的测试用例。
  2. 为弹窗组件补一个自动化的交互测试。
  3. 在文档中补充一条“弹窗行为约束”。
  4. 让后续 AI 修改弹窗代码时,能自动跑这条检查。

5. 让 AI 改“小而清晰”的模块,而不是整坨业务

OpenAI 的实践反复强调:可读性、模块化、结构化的知识,以及清晰的边界,是让 agent 高效工作的前提。

前端最怕看到的是“屎山”代码:一个页面文件 3000 行,状态、请求、视图全写在一起,组件没有职责边界。这种代码人难改,AI 更容易改坏。

更适合 AI 的前端结构,应该遵循清晰的职责划分:

  • 页面层:只负责组装。
  • 容器层:负责数据和状态管理。
  • 展示组件:纯 UI,接收 props。
  • Hooks:逻辑单独抽离。
  • Schema / Types / API:独立管理。
  • 测试:贴近模块放置。

这样,当 AI 接到一个修改任务时,它的上下文更小,误改范围也更可控,准确率自然更高。

适合前端的实际落地方案

从轻量级开始,我们分四个阶段:

第一阶段:先把"护栏"补起来

  • TypeScript 严格模式
  • ESLint / Prettier
  • Storybook
  • Playwright
  • 基础 CI

第二阶段:把团队规范文档化

  • 组件使用规范
  • 页面结构规范
  • 状态管理规范
  • 样式规范
  • 无障碍规范

第三阶段:把 AI 接进固定流程每个任务都要求 AI:

  1. 先读相关文档
  2. 只改指定目录
  3. 先补测试或补场景
  4. 生成代码
  5. 跑 lint / test / build / e2e
  6. 输出变更说明和风险点

第四阶段:积累可复用 harness把常见任务模板化:

  • "新增表单页"
  • "新增列表页"
  • "修复响应式问题"
  • "改造旧组件到设计系统"
  • "补一个 Storybook 场景"
  • "给页面加埋点和性能检查"

久而久之,你的产出就不再是"一次次 prompt",而是一套越来越稳的前端工作流。

总之,下一个阶段前端工程师的价值更多在于:

  • 定义系统边界
  • 设计组件和规范
  • 搭建验证机制
  • 控制质量门槛
  • 把经验沉淀成流程

我们要从"页面实现者",慢慢升级成"前端生产系统的设计者"。把前端项目变成一个 AI 很难写歪、写错了也会立刻被发现的系统。

在NFT项目中集成IPFS:从Pinata上传到前端展示的完整实战与踩坑

作者 竹林818
2026年3月31日 10:01

背景

上个月,我接手了一个新的NFT项目,功能挺有意思:允许用户上传一张自己的宠物照片,再选择几个属性标签(比如“活泼”、“贪吃”),前端会组合生成一个带有艺术边框和文字描述的“宠物头像”,最后用户可以把这个头像铸造为NFT。

项目逻辑跑通后,一个核心问题摆在了面前:NFT的元数据和图片存在哪里?直接丢服务器?那太中心化了,而且我这个小团队也负担不起长期存储和带宽。全放链上?一张图片动辄几百KB,Gas费能贵到天上去。所以,去中心化存储方案IPFS成了必然选择。我需要实现一个流程:用户在前端完成创作后,将图片和结构化的元数据(JSON)上传到IPFS,拿到一个永久的CID(内容标识符),最后只需要将这个CID(或由它构成的URI)写入智能合约的tokenURI函数即可。

听起来很标准,对吧?但真动手把“前端上传”到“生成合规URI”这个流程走通,里面可有不少细节和坑等着呢。

问题分析

我最开始的思路很简单:找个IPFS的HTTP接口,比如公共网关,把文件POST过去不就完了?但马上发现了几个问题:

  1. 持久化问题:IPFS网络中的文件需要被“固定”(Pin)才会被节点长期存储。公共网关上传的文件,如果没有被任何节点固定,很快就会被垃圾回收掉,你的NFT图片就“消失”了。
  2. 前端直接性:如果走项目后端服务器中转,会增加复杂度和中心化风险。我更希望前端能直接、安全地与IPFS服务交互。
  3. 元数据规范:NFT元数据JSON的结构有社区标准(比如ERC-721的tokenURI期望返回特定字段),并且其中的image字段链接需要能被钱包和市场(如OpenSea)正确解析。

排查了一圈,我决定采用 Pinata 作为固定的服务提供商,它提供了友好的API和免费的额度。核心流程定为:前端通过Pinata的API密钥,直接将文件上传至IPFS并固定,然后组合元数据JSON,再将这个JSON本身上传到IPFS,最终得到一个指向元数据的ipfs:// URI。

核心实现

第一步:设置Pinata与前端安全策略

首先,去Pinata官网注册并获取API密钥。这里有个关键的安全坑:绝对不能把API密钥硬编码在前端代码里!任何人查看页面源码或网络请求都能偷走它,然后用你的额度疯狂上传。

我的解决方案是:为这个功能单独创建一个“子密钥”(Sub-Key),并设置严格的上传次数和存储空间限制。即使密钥泄露,损失也可控。更好的方式是通过一个无服务器函数(如Vercel Edge Function)做一次代理,但为了简化首个版本,我选择了限制子密钥的策略。

我在项目根目录创建了一个.env.local文件来存储密钥:

REACT_APP_PINATA_JWT=你的JWT密钥
REACT_APP_PINATA_GATEWAY=你的专属网关域名(可选)

第二步:实现图片文件上传函数

接下来,实现第一个核心函数:将用户生成的图片文件上传到IPFS并固定。

这里我使用了axios来发起请求。Pinata的pinFileToIPFS接口需要以multipart/form-data格式上传文件。

import axios from 'axios';

// 配置Pinata API端点
const PINATA_API = `https://api.pinata.cloud/pinning/pinFileToIPFS`;
// 从环境变量读取JWT
const JWT = `Bearer ${process.env.REACT_APP_PINATA_JWT}`;

/**
 * 上传单个文件到IPFS并通过Pinata固定
 * @param file 要上传的文件对象
 * @returns 返回Pinata响应,包含IPFS哈希(CID)
 */
export const uploadFileToIPFS = async (file: File): Promise<string> => {
  // 创建FormData对象,这是上传文件的关键
  const formData = new FormData();
  formData.append('file', file);

  // Pinata允许添加额外的元数据,方便管理。这里我们把原始文件名存进去。
  const metadata = JSON.stringify({
    name: file.name,
  });
  formData.append('pinataMetadata', metadata);

  // 这是可选的,设置自定义的固定选项,比如不重复固定相同内容
  const options = JSON.stringify({
    cidVersion: 0, // 使用CID v0,兼容性更好,生成的哈希以`Qm`开头
  });
  formData.append('pinataOptions', options);

  try {
    const response = await axios.post(PINATA_API, formData, {
      headers: {
        'Content-Type': 'multipart/form-data',
        Authorization: JWT,
      },
      maxBodyLength: Infinity, // 处理大文件可能需要
    });
    // 返回的CID就是文件在IPFS上的唯一标识
    return response.data.IpfsHash;
  } catch (error) {
    console.error('Error uploading file to IPFS:', error);
    throw new Error('文件上传失败');
  }
};

注意这个细节cidVersion我设置为0。CID v0虽然长度固定且以Qm开头,但兼容性最好,几乎所有钱包和网关都认识。CID v1更灵活,但有些旧工具可能不支持。在NFT场景下,稳妥起见我先用v0。

第三步:构建并上传NFT元数据

拿到图片的CID后,我们需要构建一个符合ERC-721元数据标准的JSON对象。

interface NFTMetadata {
  name: string;
  description: string;
  image: string;
  attributes: Array<{
    trait_type: string;
    value: string;
  }>;
}

/**
 * 构建NFT元数据对象并上传到IPFS
 * @param imageCID 图片文件的IPFS CID
 * @param metadata 前端生成的元数据内容
 * @returns 返回元数据JSON文件的IPFS CID
 */
export const uploadMetadataToIPFS = async (
  imageCID: string,
  metadata: Omit<NFTMetadata, 'image'>
): Promise<string> => {
  // 构建完整的元数据对象
  const fullMetadata: NFTMetadata = {
    ...metadata,
    // 关键:image字段使用ipfs:// URI格式
    image: `ipfs://${imageCID}`,
  };

  // 注意:这里我们上传的是JSON字符串,不是文件。
  // Pinata也提供了`pinJSONToIPFS`接口专门处理JSON。
  const PINATA_JSON_API = `https://api.pinata.cloud/pinning/pinJSONToIPFS`;

  try {
    const response = await axios.post(
      PINATA_JSON_API,
      {
        pinataContent: fullMetadata, // JSON内容放在pinataContent字段
        pinataMetadata: {
          name: `${metadata.name}_metadata.json`,
        },
      },
      {
        headers: {
          'Content-Type': 'application/json',
          Authorization: JWT,
        },
      }
    );
    return response.data.IpfsHash; // 这是元数据JSON文件的CID
  } catch (error) {
    console.error('Error uploading metadata to IPFS:', error);
    throw new Error('元数据上传失败');
  }
};

这里有个大坑image字段的格式。我最初写成了https://ipfs.io/ipfs/${imageCID}。这在测试时看起来没问题,但违背了去中心化的初衷,因为它绑定了一个特定的中心化网关(ipfs.io)。正确的做法是使用ipfs://协议URI,即ipfs://${imageCID}。钱包和兼容性好的市场(如OpenSea)会用自己的网关或用户配置的网关来解析这个URI。

第四步:组装最终的Token URI并调用合约

拿到元数据JSON的CID后,最后一步就是生成智能合约需要的tokenURI。对于ERC-721,通常合约的tokenURI(uint256 tokenId)函数会返回一个字符串。我们有两种常见做法:

  1. 在铸造时,直接将完整的ipfs://${metadataCID}写入合约的_setTokenURI或对应的状态变量。
  2. 如果合约设计为返回一个基础URI加上tokenId,那么我们可以将基础URI设置为ipfs://${metadataCID}/(注意末尾斜杠),然后元数据文件需要按12这样的tokenId命名。但我们的项目是用户动态生成,每个NFT元数据都不同,所以更适合第一种“一对一”的方式。

在铸造函数中,核心代码逻辑如下:

import { useContractWrite } from 'wagmi'; // 假设使用wagmi连接合约

// 假设的合约ABI片段
const contractABI = [
  {
    name: 'mint',
    type: 'function',
    stateMutability: 'payable',
    inputs: [
      { name: 'to', type: 'address' },
      { name: 'tokenURI', type: 'string' }, // 我们直接传入完整的URI
    ],
    outputs: [],
  },
];

const MintButton: React.FC<{ metadataCID: string }> = ({ metadataCID }) => {
  const { write } = useContractWrite({
    address: contractAddress,
    abi: contractABI,
    functionName: 'mint',
  });

  const handleMint = () => {
    // 组装最终的tokenURI
    const finalTokenURI = `ipfs://${metadataCID}`;
    write({
      args: [userAddress, finalTokenURI],
      // value: mintPrice, // 如果需要支付费用
    });
  };

  return <button onClick={handleMint}>铸造NFT</button>;
};

至此,从用户图片到链上tokenURI的完整去中心化存储流程就实现了。

完整代码示例

以下是一个简化的React组件示例,串联了上述所有步骤:

// NFTMinter.tsx
import React, { useState } from 'react';
import { uploadFileToIPFS, uploadMetadataToIPFS } from './utils/ipfs';
import { useAccount, useContractWrite } from 'wagmi';
import { CONTRACT_ADDRESS, CONTRACT_ABI } from './config/contract';

const NFTMinter: React.FC = () => {
  const [imageFile, setImageFile] = useState<File | null>(null);
  const [nftName, setNftName] = useState('');
  const [status, setStatus] = useState<'idle' | 'uploading' | 'minting'>('idle');
  const { address } = useAccount();

  const { writeAsync: mintNFT } = useContractWrite({
    address: CONTRACT_ADDRESS,
    abi: CONTRACT_ABI,
    functionName: 'safeMint',
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!imageFile || !nftName || !address) return;

    setStatus('uploading');
    try {
      // 1. 上传图片
      const imageCID = await uploadFileToIPFS(imageFile);
      console.log('Image uploaded, CID:', imageCID);

      // 2. 构建并上传元数据
      const metadata = {
        name: nftName,
        description: `A unique pet avatar named ${nftName}`,
        attributes: [{ trait_type: 'Creator', value: address }],
      };
      const metadataCID = await uploadMetadataToIPFS(imageCID, metadata);
      console.log('Metadata uploaded, CID:', metadataCID);

      // 3. 调用合约铸造
      setStatus('minting');
      const finalTokenURI = `ipfs://${metadataCID}`;
      const tx = await mintNFT({
        args: [address, finalTokenURI],
      });
      await tx.wait();
      alert('NFT铸造成功!');
    } catch (error) {
      console.error('Process failed:', error);
      alert('操作失败,请查看控制台。');
    } finally {
      setStatus('idle');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>创建你的宠物NFT</h2>
      <div>
        <label>上传宠物图片:</label>
        <input
          type="file"
          accept="image/*"
          onChange={(e) => setImageFile(e.target.files?.[0] || null)}
          required
        />
      </div>
      <div>
        <label>NFT名称:</label>
        <input
          type="text"
          value={nftName}
          onChange={(e) => setNftName(e.target.value)}
          required
        />
      </div>
      <button type="submit" disabled={status !== 'idle'}>
        {status === 'uploading'
          ? '上传中...'
          : status === 'minting'
          ? '铸造中...'
          : '生成并铸造NFT'}
      </button>
    </form>
  );
};

export default NFTMinter;

踩坑记录

  1. CORS错误(跨域问题):在开发时,直接从localhost调用Pinata API遇到了CORS错误。我一开始以为是Pinata服务端配置问题,后来发现是axios请求头设置不完整。确保Content-Type根据上传类型正确设置(文件用multipart/form-data,JSON用application/json),并且Authorization头格式正确(Bearer <JWT>)。

  2. ipfs:// URI在测试环境不显示图片:在项目网站本身上用<img src=预览时,浏览器无法直接处理ipfs://协议。我的临时解决方案是,在前端展示时,使用一个公共网关或Pinata提供的专属网关进行转换,例如:const gatewayUrl = gateway.pinata.cloud/ipfs/${cid}…

  3. 文件大小限制:Pinata免费账户有单文件大小限制(比如100MB)。用户上传大文件时前端需要做校验。我增加了上传前的文件大小检查,并给出友好提示。

  4. 元数据JSON格式错误导致OpenSea不识别:第一次铸造的NFT在OpenSea上图片不显示。排查后发现是元数据JSON里image字段的网关链接失效(用了临时测试网关)。修正为ipfs://格式后,还需要确保JSON本身严格符合标准(字段名正确,没有多余的逗号)。使用JSON.stringify()生成,并用在线JSON验证器检查是个好习惯。

小结

这次集成让我彻底搞懂了NFT去中心化存储从前端到合约的完整数据流。核心收获是:“固定”服务是关键,ipfs://协议URI是标准,而前端直传需要妥善管理API密钥。下一步可以探索更去中心化的固定方式,比如使用Filecoin进行长期存储,或者集成Arweave作为另一个永久存储方案。

React 滚动效果:告别第三方库

作者 AI划重点
2026年3月31日 09:46

滚动是 Web 上最基础的用户交互。随阅读进度填充的进度条、滑动后缩小并吸顶的导航栏、打开弹窗时锁定背后页面的滚动、点击按钮平滑跳转到指定区域——这些效果几乎出现在每个现代网站上。然而在 React 中正确实现它们,意味着你要同时处理 addEventListenerIntersectionObserveroverflow 样式以及一大堆意想不到的边界情况。大多数开发者要么引入一个沉重的动画库,要么花几个小时写出脆弱的命令式代码。

本文选择另一条路。我们将逐一攻克六个常见的滚动场景,每个场景先展示手动实现,让你理解底层原理,然后用 ReactUse@reactuses/core)中对应的 Hook 替换。ReactUse 是一个开源的 React Hook 集合,提供 100 多个封装了常见浏览器和元素交互的 Hook。读完之后,你将拥有一组可组合、SSR 安全的 Hook 工具箱,涵盖滚动追踪、滚动锁定、平滑滚动、吸顶检测、可见性检测和交叉观察——全程不需要任何外部动画或滚动库。

1. 追踪滚动位置

手动实现

追踪用户的滚动距离看起来很简单,但一旦要考虑节流、方向检测以及判断用户是否滚到了边缘,复杂度就上来了。

import { useEffect, useRef, useState } from "react";

function ManualScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [scrollY, setScrollY] = useState(0);
  const [direction, setDirection] = useState<"up" | "down">("down");
  const lastY = useRef(0);

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;

    const onScroll = () => {
      const y = el.scrollTop;
      setDirection(y > lastY.current ? "down" : "up");
      lastY.current = y;
      setScrollY(y);
    };

    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, []);

  const progress = containerRef.current
    ? scrollY /
      (containerRef.current.scrollHeight - containerRef.current.clientHeight)
    : 0;

  return (
    <div>
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${progress * 100}%`,
          background: "#4f46e5",
          transition: "width 0.1s",
        }}
      />
      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {/* 长内容 */}
      </div>
    </div>
  );
}

对于一个简单的进度条来说够用了,但它无法告诉你用户是否已经滚到底部,不支持横向滚动追踪,方向检测也很粗糙——惯性滚动中一个像素的反弹就会翻转方向。如果还要加上"到达边缘"的阈值判断,状态管理和计算量会更多。

用 useScroll

useScroll 返回当前的 xy 偏移量、双轴滚动方向,以及 isScrollingarrivedState 布尔值,后者会告诉你用户是否到达了上、下、左、右边缘。

import { useScroll } from "@reactuses/core";
import { useRef } from "react";

function ScrollTracker() {
  const containerRef = useRef<HTMLDivElement>(null);

  const [position, direction, arrivedState, isScrolling] = useScroll(
    containerRef,
    { throttle: 50 }
  );

  const el = containerRef.current;
  const progress = el
    ? position.y / (el.scrollHeight - el.clientHeight)
    : 0;

  return (
    <div>
      {/* 进度条 */}
      <div
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          height: 4,
          width: `${Math.min(progress * 100, 100)}%`,
          background: "#4f46e5",
          zIndex: 50,
        }}
      />

      {/* 滚动信息浮层 */}
      <div
        style={{
          position: "fixed",
          bottom: 16,
          right: 16,
          padding: "8px 16px",
          background: "#1e293b",
          color: "#fff",
          borderRadius: 8,
          fontSize: 14,
          zIndex: 50,
        }}
      >
        <div>Y: {Math.round(position.y)}px</div>
        <div>方向: {direction.y ?? "无"}</div>
        <div>
          {arrivedState.bottom
            ? "已到达底部!"
            : isScrolling
              ? "滚动中..."
              : "空闲"}
        </div>
      </div>

      <div
        ref={containerRef}
        style={{ height: "100vh", overflow: "auto" }}
      >
        {Array.from({ length: 100 }, (_, i) => (
          <p key={i} style={{ padding: "8px 16px" }}>
            第 {i + 1} 段
          </p>
        ))}
      </div>
    </div>
  );
}

一次 Hook 调用就替代了所有手动事件绑定、方向追踪和边缘检测。内置的 throttle 选项保证即使在高频 scroll 事件下也能保持流畅。

2. 弹窗滚动锁定

手动实现

打开弹窗时,你需要阻止弹窗背后的页面继续滚动。经典做法是给 body 加上 overflow: hidden

import { useEffect, useState } from "react";

function ManualModal() {
  const [isOpen, setIsOpen] = useState(false);

  useEffect(() => {
    if (isOpen) {
      const scrollY = window.scrollY;
      document.body.style.position = "fixed";
      document.body.style.top = `-${scrollY}px`;
      document.body.style.width = "100%";
      document.body.style.overflow = "hidden";

      return () => {
        document.body.style.position = "";
        document.body.style.top = "";
        document.body.style.width = "";
        document.body.style.overflow = "";
        window.scrollTo(0, scrollY);
      };
    }
  }, [isOpen]);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>背后的页面无法滚动。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

桌面浏览器上没问题,但 position: fixed 这个技巧在 iOS Safari 上会导致页面跳动——除非你小心保存和恢复滚动位置。它也没有处理多层弹窗叠加的情况。

用 useScrollLock

useScrollLock 帮你处理了所有这些边界情况。传入要锁定的元素引用(通常是 document.body)和一个控制锁定状态的布尔值。

import { useScrollLock } from "@reactuses/core";
import { useState } from "react";

function Modal() {
  const [isOpen, setIsOpen] = useState(false);

  useScrollLock(
    typeof document !== "undefined" ? document.body : null,
    isOpen
  );

  return (
    <>
      <button onClick={() => setIsOpen(true)}>打开弹窗</button>
      {isOpen && (
        <div
          style={{
            position: "fixed",
            inset: 0,
            background: "rgba(0,0,0,0.5)",
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            zIndex: 100,
          }}
        >
          <div
            style={{
              background: "#fff",
              padding: 24,
              borderRadius: 12,
              maxWidth: 400,
            }}
          >
            <h2>弹窗标题</h2>
            <p>滚动已锁定,试试滑动背后的页面。</p>
            <button onClick={() => setIsOpen(false)}>关闭</button>
          </div>
        </div>
      )}
    </>
  );
}

一行代码锁定滚动,组件卸载时自动解锁,SSR 环境下也安全无虞。滚动位置在所有浏览器上都能正确保留。

3. 平滑滚动到指定区域

手动实现

落地页上常见的"滚动到某区域"按钮,命令式的写法如下:

import { useRef } from "react";

function ManualScrollTo() {
  const sectionRef = useRef<HTMLDivElement>(null);

  const scrollToSection = () => {
    sectionRef.current?.scrollIntoView({
      behavior: "smooth",
      block: "start",
    });
  };

  return (
    <div>
      <nav style={{ position: "fixed", top: 0, padding: 16, zIndex: 10 }}>
        <button onClick={scrollToSection}>跳转到功能介绍</button>
      </nav>

      <div style={{ height: "100vh", background: "#f1f5f9" }}>
        <h1 style={{ paddingTop: 80 }}>首屏区域</h1>
      </div>

      <div ref={sectionRef} style={{ padding: 40 }}>
        <h2>功能介绍</h2>
        <p>功能详情…</p>
      </div>
    </div>
  );
}

scrollIntoView 对基本场景够用,但它无法控制缓动曲线、滚动轴和偏移量(当你有一个固定头部时,偏移量就很重要了)。同时也没有办法知道滚动动画何时完成。

用 useScrollIntoView

useScrollIntoView 提供了对滚动动画的精细控制,包括自定义时长、缓动函数、滚动轴、偏移量和完成回调。

import { useScrollIntoView } from "@reactuses/core";
import { useRef } from "react";

function SmoothScrollPage() {
  const targetRef = useRef<HTMLDivElement>(null);

  const { scrollIntoView } = useScrollIntoView(targetRef, {
    duration: 800,
    offset: 80, // 为固定头部留出空间
  });

  return (
    <div>
      <nav
        style={{
          position: "fixed",
          top: 0,
          left: 0,
          right: 0,
          height: 64,
          background: "#1e293b",
          display: "flex",
          alignItems: "center",
          padding: "0 24px",
          zIndex: 50,
        }}
      >
        <button
          onClick={() => scrollIntoView({ alignment: "start" })}
          style={{
            background: "#4f46e5",
            color: "#fff",
            border: "none",
            padding: "8px 16px",
            borderRadius: 6,
            cursor: "pointer",
          }}
        >
          跳转到定价
        </button>
      </nav>

      <div style={{ height: "150vh", paddingTop: 80 }}>
        <h1>首屏</h1>
        <p>向下滚动或点击上方按钮。</p>
      </div>

      <div ref={targetRef} style={{ padding: 40, background: "#eef2ff" }}>
        <h2>定价方案</h2>
        <p>详细的套餐和价格信息…</p>
      </div>

      <div style={{ height: "100vh" }} />
    </div>
  );
}

offset 选项确保目标区域出现在固定头部下方,而不是被遮挡。平滑滚动动画使用可配置的缓动函数,如果组件在滚动过程中卸载,Hook 也会正确清理。

4. 吸顶检测

手动实现

一个常见的交互模式是:当 header 吸顶后改变外观,比如加上阴影、缩小高度。手动检测需要借助 IntersectionObserver 和一个哨兵元素:

import { useEffect, useRef, useState } from "react";

function ManualStickyHeader() {
  const sentinelRef = useRef<HTMLDivElement>(null);
  const [isStuck, setIsStuck] = useState(false);

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        setIsStuck(!entry.isIntersecting);
      },
      { threshold: 0 }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, []);

  return (
    <div>
      <div ref={sentinelRef} style={{ height: 1 }} />
      <header
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck ? "rgba(255,255,255,0.95)" : "#fff",
          boxShadow: isStuck ? "0 2px 8px rgba(0,0,0,0.1)" : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

哨兵方案能用但很脆弱:你需要精确地放置哨兵元素,管理观察者的生命周期,并在 DOM 结构变化时保持同步。

用 useSticky

useSticky 干净利落地解决了吸顶检测问题,返回一个布尔值,当元素进入吸顶状态时翻转为 true

import { useSticky } from "@reactuses/core";
import { useRef } from "react";

function StickyHeader() {
  const headerRef = useRef<HTMLElement>(null);
  const [isStuck] = useSticky(headerRef);

  return (
    <div>
      <header
        ref={headerRef}
        style={{
          position: "sticky",
          top: 0,
          padding: isStuck ? "8px 24px" : "16px 24px",
          background: isStuck
            ? "rgba(255,255,255,0.95)"
            : "#fff",
          boxShadow: isStuck
            ? "0 2px 8px rgba(0,0,0,0.1)"
            : "none",
          transition: "all 0.2s",
          zIndex: 40,
        }}
      >
        <h1 style={{ margin: 0, fontSize: isStuck ? 18 : 24 }}>
          我的应用
        </h1>
      </header>
      <main style={{ padding: 24 }}>
        {Array.from({ length: 80 }, (_, i) => (
          <p key={i}>内容段落 {i + 1}</p>
        ))}
      </main>
    </div>
  );
}

不需要哨兵元素,不需要手动设置观察者。Hook 在内部完成检测,给你一个简单的响应式布尔值来驱动样式。

5. 滚动进入视口时的渐显效果

用 useElementVisibility

useElementVisibilityIntersectionObserver 封装成一个布尔值返回。搭配 useState 标记位即可实现单次渐显效果:

import { useElementVisibility } from "@reactuses/core";
import { useRef, useState, useEffect } from "react";

function RevealOnScroll({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [visible] = useElementVisibility(ref);
  const [hasRevealed, setHasRevealed] = useState(false);

  useEffect(() => {
    if (visible && !hasRevealed) {
      setHasRevealed(true);
    }
  }, [visible, hasRevealed]);

  return (
    <div
      ref={ref}
      style={{
        opacity: hasRevealed ? 1 : 0,
        transform: hasRevealed ? "translateY(0)" : "translateY(30px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

function FeaturePage() {
  return (
    <div style={{ padding: "100vh 24px 24px" }}>
      <RevealOnScroll>
        <h2>功能一</h2>
        <p>滚动到视口内时淡入显示。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能二</h2>
        <p>每个区域独立动画。</p>
      </RevealOnScroll>
      <div style={{ height: 200 }} />
      <RevealOnScroll>
        <h2>功能三</h2>
        <p>只动画一次——回滚时不会闪烁。</p>
      </RevealOnScroll>
    </div>
  );
}

6. 高级交叉观察:滚动进度指示

useIntersectionObserver 以声明式的方式暴露完整的 IntersectionObserver API,让你直接获取 IntersectionObserverEntry,包括 intersectionRatioisIntersectingboundingClientRect

import { useIntersectionObserver } from "@reactuses/core";
import { useRef, useState } from "react";

function SectionProgress() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const [ratio, setRatio] = useState(0);

  useIntersectionObserver(
    sectionRef,
    ([entry]) => {
      setRatio(entry.intersectionRatio);
    },
    {
      threshold: Array.from({ length: 101 }, (_, i) => i / 100),
    }
  );

  return (
    <div>
      <div style={{ height: "100vh" }} />
      <div ref={sectionRef} style={{ minHeight: "100vh", padding: 40 }}>
        <div
          style={{
            position: "sticky",
            top: 20,
            width: 200,
            height: 8,
            background: "#e2e8f0",
            borderRadius: 4,
          }}
        >
          <div
            style={{
              height: "100%",
              width: `${ratio * 100}%`,
              background: "#4f46e5",
              borderRadius: 4,
              transition: "width 0.1s",
            }}
          />
        </div>
        <h2>长篇区域</h2>
        {Array.from({ length: 20 }, (_, i) => (
          <p key={i}>区域中的第 {i + 1} 段。</p>
        ))}
      </div>
      <div style={{ height: "100vh" }} />
    </div>
  );
}

Hook 负责管理观察者的生命周期,在选项变化时重新连接,在卸载时自动清理。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100 多个 React Hook。浏览全部 →

线上复制按钮失效?也许是这个原因

作者 Shrimp
2026年3月31日 09:39

问题描述

开发聊天组件时,在本地测试复制功能完全正常,部署到服务器后点击复制按钮却毫无反应。没有报错,没有提示,就像什么都没发生一样。

问题定位

翻了半天代码,发现复制逻辑是这样的:

const handleCopy = useCallback(async () => {
  try {
    await navigator.clipboard.writeText(text);
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  } catch {
    // 空的,什么都没做
  }
}, [text]);

逻辑看起来没问题,但问题出在 navigator.clipboard.writeText

navigator.clipboard API 需要在安全上下文(Secure Context)下才能工作。所谓安全上下文,就是页面通过 HTTPS 加载,或者在 localhost 下运行。

本地开发时,浏览器把 localhost 视为安全上下文,所以 Clipboard API 正常工作。部署到服务器后,如果你的域名是 HTTP 而非 HTTPS,navigator.clipboard 虽然存在,但调用 writeText 时会直接静默失败。由于 catch 块是空的,用户点击后什么反馈都没有,看起来就像按钮坏了。

解决方案

提供一个不依赖安全上下文的降级方案:

const handleCopy = useCallback(async () => {

  try {
    if (navigator?.clipboard?.writeText) {
      await navigator.clipboard.writeText(text);
    } else {
      throw new Error('Clipboard API not available');
    }
  } catch {
    // Fallback: textarea + execCommand,不需要安全上下文
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.style.position = 'fixed';
    textarea.style.opacity = '0';
    document.body.appendChild(textarea);
    textarea.focus();
    textarea.select();
    try {
      document.execCommand('copy');
    } catch {
      // 所有复制方式均失败,静默处理
    } finally {
      document.body.removeChild(textarea);
    }
  }
}, [text, copied]);

这个方案有两个层次:

第一层,优先尝试 navigator.clipboard.writeText,这是现代标准 API,体验最好。

第二层,当 Clipboard API 不可用或失败时,降级到 textarea + execCommand。这个方式不需要 HTTPS,在任何环境下都能工作,是最后的兜底。

更好的做法

降级方案虽然能解决问题,但 execCommand 已经是被废弃的 API,长期来看不是最优解。根本的解决方式是给服务器配置 HTTPS。

上了 HTTPS 后,Clipboard API 在所有现代浏览器下都能正常工作,就不需要降级方案了。

总结

这个问题本质上是安全上下文的要求导致的:本地 localhost 默认是安全的,线上非 HTTPS 则不安全。解决方案是两层兜底:优先用标准 Clipboard API,失败后降级到 execCommand。但更推荐的做法是直接给站点加上 HTTPS,一劳永逸。


关键词:navigator.clipboard、安全上下文、HTTPS、execCommand、复制功能失效

Vue 学习总结(Java 后端工程师视角)

作者 有志
2026年3月31日 09:27

基于前后端分离项目(Vue3 + Vite + TypeScript + Pinia + Element Plus)全程类比 SpringBoot 思维,新手可直接上手

一、项目结构与核心文件作用

1. 整体结构

plaintext

项目根目录
├── .vscode/           VSCode 编辑器配置
├── node_modules/      第三方依赖库(≈ Maven 本地仓库)
├── public/            静态资源(≈ SpringBoot static)
├── src/               业务源码(主要开发目录)
├── dist/              打包后部署文件(≈ target/)
├── package.json       依赖 + 启动脚本(≈ pom.xml)
└── index.html         项目入口页面

2. 关键文件说明

  • package.json:管理依赖、定义 dev/build/lint 等命令
  • node_modules/npm install 生成,不提交 Git
  • dist/npm run build 后生成,用于部署
  • public/ :存放 favicon.ico 等不参与编译的静态资源
  • src/ :页面、组件、接口、状态、路由均在此

二、项目运行与启动流程

1. 常用命令

bash

运行

npm run dev      # 启动开发服务(≈ mvn spring-boot:run)
npm run build    # 生产打包(≈ mvn clean package)
npm install      # 安装依赖

2. 浏览器启动 Vue 流程

  1. 访问地址 → 加载 index.html
  2. 解析并加载 main.ts(项目入口)
  3. 创建 Vue 实例,挂载到 #app 节点
  4. 加载路由 → 根据 URL 渲染对应页面

3. 入口文件 main.ts(≈ 启动类)

ts

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

三、Pinia 状态管理(核心重点)

1. 是什么?

Pinia = 前端的 @Service + 全局单例 Bean + 内存缓存用于:全局共享数据、业务逻辑封装、接口调用、权限状态。

2. 基本结构

ts

import { defineStore } from 'pinia'

export const useXxxStore = defineStore('唯一标识', {
  state: () => ({
    // 数据 ≈ 类成员变量
  }),
  actions: {
    // 方法 ≈ 业务逻辑(支持 async/await)
  },
  getters: {
    // 计算属性 ≈ getXxx()
  }
})

3. async /await 说明

  • async:标记为异步方法
  • await:等待异步操作完成(如接口请求)
  • 本质:异步执行,但写法像同步,避免回调嵌套

四、前后端分离接口封装

1. API 层(≈ Mapper / Dao)

ts

// src/api/student.ts
import request from './index'

export interface Student {
  id: number
  name: string
  className: string
}

export function getStudentList(params: { page: number; pageSize: number }) {
  return request<{ list: Student[]; total: number }>({
    url: '/student/list',
    method: 'get',
    params
  })
}

2. 请求工具统一携带 Token

ts

// src/api/index.ts
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL
})

// 请求拦截器自动加 token
request.interceptors.request.use(config => {
  const authStore = useAuthStore()
  if (authStore.token) {
    config.headers.Authorization = `Bearer ${authStore.token}`
  }
  return config
})

export default request

五、路由 Vue Router(≈ @RequestMapping)

配置示例

ts

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  {
    path: '/student',
    component: () => import('@/views/StudentList.vue'),
    meta: { requiresAuth: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router

六、完整可运行示例:学生管理

1. 学生 Store(≈ Service)

ts

// src/stores/studentStore.ts
import { defineStore } from 'pinia'
import { getStudentList, Student } from '@/api/student'

export const useStudentStore = defineStore('student', {
  state: () => ({
    list: [] as Student[],
    total: 0,
    page: 1,
    pageSize: 10,
    loading: false
  }),

  actions: {
    async fetchList() {
      this.loading = true
      try {
        const res = await getStudentList({
          page: this.page,
          pageSize: this.pageSize
        })
        this.list = res.list
        this.total = res.total
      } finally {
        this.loading = false
      }
    }
  }
})

2. 学生列表页面(≈ Controller + View)

vue

// src/views/StudentList.vue
<template>
  <el-card>
    <el-table :data="studentStore.list" v-loading="studentStore.loading">
      <el-table-column label="ID" prop="id" />
      <el-table-column label="姓名" prop="name" />
      <el-table-column label="班级" prop="className" />
    </el-table>

    <el-pagination
      v-model:current-page="studentStore.page"
      v-model:page-size="studentStore.pageSize"
      :total="studentStore.total"
      @current-change="studentStore.fetchList"
      style="margin-top:16px; text-align:right"
    />
  </el-card>
</template>

<script setup lang="ts">
import { useStudentStore } from '@/stores/studentStore'
const studentStore = useStudentStore()

studentStore.fetchList()
</script>

七、全局字典缓存(DictStore)

作用

统一管理性别、班级、状态等下拉数据,全局只请求一次

ts

// src/stores/dictStore.ts
import { defineStore } from 'pinia'
import { getDictData } from '@/api/dict'

export const useDictStore = defineStore('dict', {
  state: () => ({
    gender: [],
    classes: []
  }),

  actions: {
    async loadDict() {
      const res = await getDictData()
      this.gender = res.gender
      this.classes = res.classes
    }
  }
})

使用方式

ts

// App.vue 中全局加载一次
const dictStore = useDictStore()
dictStore.loadDict()

// 页面中直接使用缓存
dictStore.gender
dictStore.classes

八、前后端 MVC 对应关系

表格

前端技术 Java 后端对应
Vue 页面 Controller + View
Pinia Store Service 层
API 接口 Mapper / Dao
Pinia state 成员变量 / 缓存
axios RestTemplate / WebClient
Vue Router @RequestMapping
package.json pom.xml
node_modules Maven 依赖库
dist 目录 target 编译目录

九、核心知识点总结

  1. Vue 是前端框架,相当于浏览器里的 SpringBoot
  2. Pinia 是状态管理,相当于全局 Service + 缓存
  3. async/await 用于异步请求后端接口
  4. API 层统一封装接口,便于维护和加 Token
  5. 路由控制页面跳转,类似后端接口路由
  6. 项目结构、分层思想与后端高度一致

十、学习路线(后端工程师推荐)

  1. 项目结构与文件作用
  2. 启动流程与 main.ts
  3. Pinia 基本使用
  4. 接口调用与 axios 封装
  5. 路由与登录权限
  6. 表格、表单、增删改查
  7. 全局字典、缓存、权限控制

前端异常监控:从 window.onerror 到完整的错误追踪方案

2026年3月31日 08:59

前端异常监控:从 window.onerror 到完整的错误追踪方案

为什么用户已经遇到“白屏”“按钮无响应”,而你的监控系统却毫无记录?

为什么控制台没有报错,但页面就是无法正常工作?

很可能不是没有错误,而是你的监控根本没有覆盖到这些场景。

在前端领域,异常并不只有一种形式,而捕获方式也完全不同。如果只依赖 window.onerror,你实际上只看到了问题的一个切面。

为什么你以为的"已经监控了"其实是裸奔

大部分项目的异常监控起点都差不多:在 main.js 里加一个 window.onerror,觉得万事大吉。

window.onerror = function (msg, url, line, col, error) {
  console.log('捕获到错误:', msg)
}

前端异常的四大类型

线上遇到的异常大致分四类,每一类的捕获方式都不一样:

第一类:JS 运行时错误。 变量未定义、类型错误、语法错误。window.onerror 能搞定大部分,但有个前提——跨域脚本的错误只会给你一个 Script error.,什么有用信息都没有。

第二类:Promise 异常。 async/await 没包 try/catch.then() 链里抛的错。这类错误 window.onerror 完全无感知,只会触发 unhandledrejection 事件。我们重构后白屏的元凶就在这里——迁移到 Vue 3 Composition API 后,大量逻辑变成了 async 函数,但全局的 rejection 处理没有人加。

第三类:资源加载错误。 图片 404、CDN 上的 JS 文件挂了、CSS 加载失败。这类错误不会冒泡到 window.onerror,只能通过 window.addEventListener('error', ...) 在捕获阶段拦截。

第四类:框架层错误。 Vue 的组件渲染异常、React 的生命周期崩溃。框架通常有自己的错误边界机制,比如 Vue 的 app.config.errorHandler 和 React 的 ErrorBoundary,不走原生的 onerror

一行 window.onerror 只覆盖了四分之一的场景,这不是裸奔是什么?

一个真实的漏网案例

当时有一个数据导出功能,代码大概长这样:

async function handleExport() {
  const data = await fetchReportData(filters) // 接口偶尔超时
  const blob = generateExcel(data)            // data 为 undefined 时直接炸
  downloadFile(blob, 'report.xlsx')
}

接口超时的时候 fetchReportData reject 了,但外面没有 try/catchwindow.onerror 一脸无辜——它确实没收到任何通知。用户点了导出按钮,什么都没发生,工单就来了。如果项目初期就把 unhandledrejection 加上,至少能在监控后台看到这个错误,而不是等用户来报。

数据上报策略:别让监控本身成为性能瓶颈

上报方式的选择

常见的上报方式有四种,各有取舍:

  • XMLHttpRequest 最传统,但页面卸载时请求会被取消,beforeunload 里的错误容易丢失。
  • Image beacon(1x1 像素图片) 简单可靠不受跨域限制,但只能 GET,URL 长度有限制,复杂的错误信息塞不下。
  • navigator.sendBeacon 异步非阻塞,页面卸载时也能保证发送,支持 POST——推荐作为首选方案。
  • fetch + keepalive 和 sendBeacon 类似,但 API 更灵活,可以设置自定义 header。

我们最终用的是 sendBeacon 优先、fetch + keepalive 兜底、Image beacon 降级的方案。因为 sendBeacon 在极少数浏览器环境下会失败(比如某些 WebView),需要一个降级链路。

采样和聚合

线上日活几十万的项目,如果每个错误都实时上报,监控服务器先扛不住。我们踩过这个坑——上线第一天,错误上报的 QPS 把监控服务的日志盘打满了。采样策略分三层:相同错误 10 秒内去重只报一次;高频错误(超过 100 次的)只采样 10%;已知的第三方脚本错误直接忽略。在此基础上再做批量上报——攒 3 秒发一批,减少请求数。

const reportQueue = []
let timer = null

function queueReport(errorData) {
  reportQueue.push(errorData)
  if (!timer) {
    timer = setTimeout(() => {
      if (reportQueue.length > 0) {
        navigator.sendBeacon('/api/monitor/report', JSON.stringify(reportQueue))
        reportQueue.length = 0
      }
      timer = null
    }, 3000)
  }
}

window.addEventListener('beforeunload', () => {
  if (reportQueue.length > 0) {
    navigator.sendBeacon('/api/monitor/report', JSON.stringify(reportQueue))
    reportQueue.length = 0
  }
})

批量上报策略上线后,监控服务的请求量降了 80%。beforeunload 里的兜底发送也很关键——不加的话,用户 3 秒内关掉页面,攒着的错误就全丢了。

踩坑清单和边界情况

做了三个月的错误监控,踩的坑比写的业务代码还多。挑几个最典型的聊聊:

Script error. 跨域问题

CDN 上的 JS 文件如果和页面不同源,window.onerror 只能拿到一个 Script error. 字符串,没有堆栈、没有行列号。解决方案需要两步配合,缺一不可:

<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>

CDN 服务器响应头也要加上 Access-Control-Allow-Origin: *。我们之前只加了 crossorigin 属性没配 CDN 的响应头,折腾了好几天才定位到原因。

错误风暴

有一次某个接口挂了,前端一个轮询逻辑每 500ms 调一次这个接口,每次都报错。1 分钟内产生了上万条错误上报,不仅监控服务扛不住,用户的浏览器也因为频繁的网络请求变卡了。这件事之后我们加了熔断机制:1 分钟内超过 50 个错误就触发熔断,上报一条特殊的"熔断触发"事件让后台知道数据不完整,5 分钟后自动恢复。

let errorCount = 0
let circuitBreakerOpen = false

// 滑动窗口:每分钟重置一次错误计数,避免正常低频错误长期累加触发熔断
setInterval(() => {
  if (!circuitBreakerOpen) {
    errorCount = 0
  }
}, 60 * 1000)

function reportWithCircuitBreaker(errorData) {
  if (circuitBreakerOpen) return

  errorCount++
  if (errorCount > 50) {
    circuitBreakerOpen = true
    navigator.sendBeacon('/api/monitor/report', JSON.stringify({
      type: 'circuit_breaker',
      message: `Error storm detected: ${errorCount} errors in 1 min`,
    }))
    setTimeout(() => {
      circuitBreakerOpen = false
      errorCount = 0
    }, 5 * 60 * 1000)
  }

  queueReport(errorData)
}

监控代码本身出错

这个最尴尬。有一次我们在格式化错误堆栈时调用了一个未做空值判断的方法,监控模块自己抛了异常,这个异常又被全局的 onerror 捕获后送回监控模块处理,形成了死循环,直接把用户浏览器标签页卡死了。

所以监控代码内部一定要有自己的 try/catch,而且要和业务错误上报走不同的路径。具体做法是给监控模块的每个核心函数都包一层防护,出了错只用 console.warn 记录,绝对不能再进入上报逻辑:

function safeExecute(fn, fallback) {
  try {
    return fn()
  } catch (e) {
    // 监控内部错误走独立路径,仅 console 输出,不进入上报队列
    console.warn('[Monitor Internal Error]', e)
    // 如果需要感知监控自身的健康状态,可以走一个独立的轻量上报端点
    try {
      navigator.sendBeacon('/api/monitor/self-check', JSON.stringify({
        type: 'monitor_internal_error',
        message: e.message,
        timestamp: Date.now(),
      }))
    } catch (_) {
      // 兜底上报也失败了,彻底放弃,不能再套娃
    }
    return fallback
  }
}

// 使用示例:在错误采集入口包一层
window.onerror = function (msg, url, line, col, error) {
  safeExecute(() => {
    const formatted = formatError(error) // 这一步可能出错
    reportWithCircuitBreaker(formatted)
  }, undefined)
}

关键原则是隔离:监控代码的异常和业务异常必须走两条完全独立的通道。我们后来还加了一个计数器,如果 safeExecute 在 1 分钟内连续触发超过 5 次内部错误,就自动禁用整个监控模块并上报一条降级通知,避免有缺陷的监控代码持续影响用户体验。

Source Map 还原:让线上堆栈变得可读

线上代码都是压缩混淆过的,捕获到的错误堆栈类似 a.js:1:28432,根本没法定位问题。Source Map 还原是让监控体系真正可用的关键一环。

核心思路是:构建时生成 Source Map 文件并上传到监控服务端,线上收到错误后在服务端做堆栈还原,绝对不要把 Source Map 部署到生产环境,否则等于把源码公开了。

Webpack 的配置如下:

// webpack.prod.js
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin')

module.exports = {
  devtool: 'hidden-source-map', // 生成 .map 文件但不在 bundle 中引用
  plugins: [
    // 如果用自建服务,可以替换为自定义上传插件
    sentryWebpackPlugin({
      org: 'your-org',
      project: 'your-project',
      authToken: process.env.SENTRY_AUTH_TOKEN,
      sourcemaps: {
        assets: './dist/**',        // 上传 dist 目录下的所有 .map 文件
        filesToDeleteAfterUpload: './dist/**/*.map', // 上传后删除本地 .map,防止部署到线上
      },
      release: {
        name: process.env.GIT_COMMIT_SHA, // 用 commit hash 作为 release 标识
      },
    }),
  ],
}

如果是自建监控服务而不用 Sentry,可以在 CI/CD 流水线里用一个简单的上传脚本代替插件:

# CI 流水线中,构建完成后上传 Source Map
for file in dist/js/*.map; do
  curl -X POST "${MONITOR_API}/sourcemap/upload" \
    -F "file=@${file}" \
    -F "release=${GIT_COMMIT_SHA}" \
    -H "Authorization: Bearer ${UPLOAD_TOKEN}"
done
# 上传完成后删除 .map 文件,确保不会被部署
rm -f dist/js/*.map

服务端收到错误上报后,根据错误信息中的文件名和 release 版本号匹配对应的 Source Map 文件,用 source-map 这个 npm 包做位置还原,就能把 a.js:1:28432 还原成 src/views/Dashboard.vue:142:8,直接定位到源码行。

三个月自建的效果和教训

整个监控体系上线后,和之前"裸奔"状态对比:

指标 上线前 上线后
错误发现方式 等用户提工单 自动告警,平均 2 分钟内触达
问题定位耗时 平均 4 小时 平均 30 分钟
错误覆盖率 仅 JS 运行时错误(约 25%) 四类异常全覆盖
周均客服工单数 30+ 降到 5 个以内
告警响应率 无告警 85%(优化告警策略后)

最大的教训是:不要等出了问题才想起做监控。 哪怕项目初期只花半天时间把四类异常的捕获加上、配一个最简单的告警,也比事后手忙脚乱地补好得多。监控应该是项目脚手架的一部分,和 ESLint、CI 流水线一样,从第一天就在。

第二个教训是监控的目的不是收集数据,而是缩短从"用户遇到问题"到"开发定位问题"的时间。我们前期过于关注"捕获率",堆了大量的上报数据,但告警规则没配好、Source Map 还原不稳定、错误列表没有按影响面排序。结果监控后台每天几千条错误,团队看都不看。后来花了两周重新梳理告警策略——只对新增错误和影响超过 100 个用户的错误发告警,响应率从 10% 升到了 85%。

前端异常监控的本质就是一条信息管道:采集、传输、存储、分析、行动。任何一个环节断了,整条链路就废了。从 window.onerror 到完整的错误追踪方案,技术上并没有多复杂,复杂的是把每个环节都做到可靠,而且不给业务添乱。

如果你的项目现在还只有一行 window.onerror,不用一步到位。先把 unhandledrejection 加上,五分钟搞定,能多覆盖 40% 的错误。然后加 Source Map 还原、加面包屑、加采样策略,一步一步来。先搭起来再迭代,比事后补救强一万倍。

React Fiber 的主要目标是什么

作者 愿你如愿
2026年3月31日 08:40

一句话总结:
React Fiber 的主要目标是解决复杂应用下的主线程阻塞问题,实现“可中断、可恢复、带优先级”的增量渲染,从而赋予 React 真正的并发能力(Concurrent Rendering)。

在 React 15 及之前,更新是同步且不可中断的,一旦开始渲染,整个组件树必须一次性完成。Fiber 通过将渲染任务拆分为小的工作单元,并利用浏览器的空闲时间执行,彻底改变了这一局面。


1. 与旧架构的比较:为什么要重构?

为了突出技术深度,我们需要对比 Stack Reconciler(旧)  和 Fiber Reconciler(新)

维度 旧架构 (Stack Reconciler) 新架构 (Fiber Reconciler)
执行方式 同步递归,不可中断 异步循环,可中断、可恢复
任务粒度 粗粒度,一次性更新整棵树 细粒度,拆分为单个节点的工作单元
主线程占用 容易长时间占用,导致掉帧 分片执行,利用空闲时间,避免阻塞
优先级 无优先级概念,所有更新一视同仁 支持优先级调度,高优先级可打断低优先级
场景举例:
想象一个大型列表的渲染。在旧架构中,如果列表数据量巨大,React 会一直计算直到完成,期间用户的点击、输入都无法响应,页面呈现“假死”状态。而在 Fiber 架构下,React 会渲染一部分,然后停下来响应你的点击,之后再继续渲染剩下的部分。

2. 源码层面的实现原理:Fiber 是什么?

从源码角度看,Fiber 包含两层含义:一种数据结构 和 一种执行模型

A. 数据结构:链表化的树

在源码中,每个组件(DOM、Class、Function)都对应一个 Fiber 节点。它是一个 JavaScript 对象,核心字段如下:

  • type: 组件类型(如 divMyComponent)。

  • key: 唯一标识。

  • child / sibling / return: 分别指向第一个子节点、下一个兄弟节点、父节点。

    • 技术点:React 放弃了使用递归(调用栈)来遍历树,而是利用这三个指针构建了链表结构。这使得遍历可以随时暂停和恢复,因为不需要依赖函数调用栈。
  • stateNode: 指向组件实例或 DOM 节点。

  • alternate: 指向当前 Fiber 节点的“镜像”节点(用于双缓冲机制)。

B. 双缓冲机制 (Double Buffering)

Fiber 维护了两棵树:

  1. Current Tree: 当前屏幕上显示的 UI 对应的 Fiber 树。
  2. WorkInProgress Tree: 内存中正在构建的新树。

更新时,React 会基于 Current Tree 构建 WorkInProgress Tree。一旦构建完成(Commit 阶段),两棵树会互换指针,WorkInProgress 瞬间变为 Current,实现 UI 的无闪烁更新。


3. 性能优化机制:它是如何工作的?

Fiber 的性能优化主要体现在调度执行流程上。

A. 时间切片 (Time Slicing) 与 调度

Fiber 的工作流程分为两个阶段:

  1. Render 阶段 (协调阶段)

    • 可中断:这是 Fiber 的核心。React 会遍历 Fiber 树,对比差异。每处理完一个节点,都会检查时间片是否用完(通常约 5ms)。如果超时,React 会主动让出主线程控制权,去处理用户交互或动画,等浏览器空闲时再回来继续。
    • 纯内存操作:此阶段不修改真实 DOM,只生成副作用列表(Effect List)。
  2. Commit 阶段 (提交阶段)

    • 不可中断:一旦进入此阶段,必须同步执行完成。它负责将 Render 阶段收集到的副作用(增删改 DOM、执行生命周期)一次性应用到真实 DOM 上,保证 UI 的一致性。

B. 优先级调度 (Priority Scheduling)

Fiber 引入了 Lane 模型(位运算优先级),将更新分为不同等级:

  • 高优先级:用户输入、动画。
  • 低优先级:网络请求数据、后台统计。

实际场景:在一个搜索框中,输入文字是“高优先级”,必须立即响应;而根据输入内容去服务器请求数据并更新列表是“低优先级”。Fiber 允许高优先级的输入更新打断低优先级的列表渲染,确保输入框不卡顿。


4. 总结:Fiber 的核心价值

综上所述,React Fiber 的主要目标是通过重构协调引擎,实现了以下价值:

  1. 增量渲染:将大任务拆小,避免长任务阻塞主线程。
  2. 并发能力:通过优先级调度和可中断渲染,让 React 具备了处理并发更新的能力(Concurrent Mode 的基础)。
  3. 更好的用户体验:确保关键的用户交互(如点击、输入)永远优先于 UI 的更新渲染。

这就是 Fiber 存在的意义——它让 React 从一个单纯的视图库,进化为一个能够智能调度渲染、适应复杂高性能场景的框架。

# 7天 0 到 1 全 AI 驱动:我用 AI Agent 落地了一套完整低代码搭建系统——【OrangeHome】

2026年3月30日 23:42

前言

AI技术正以前所未有的速度迭代,各类智能体和开发工具层出不穷。为了深入探索AI驱动的研发流程闭环,并真实地摸清当前AI能力的边界,我决定:从0到1,独立开发一个相对复杂的系统,全程由AI负责执行,而“人”只扮演管理者的角色。

本文将完整复盘这个项目的启动、开发与调试阶段。关于后续的项目部署、迭代与运维等环节,我会在系列文章的后续章节中持续更新,敬请期待。

概览

使用的AI工具链

  • 核心开发:Cursor + 2个OpenClaw
  • 辅助工具:Speckit、GSD (get-shit-done)
  • 需求探讨:DeepSeek Chat + OpenClaw

项目整体架构

OrangeHome 无代码/低代码搭建平台整体架构:

whiteboard_exported_image.png

项目仓库

采用多仓库协作模式,与真实研发场景高度贴合:

仓库名称 仓库类型 仓库描述 仓库地址
orangehome-main-monorepo Rush monorepo 应用层仓库,包含BFF和Web github.com/ponyorange/…
orangehome-materials Turbo repo 低代码平台物料 github.com/ponyorange/…
orangehome-core-service 单仓 低代码搭建核心能力 github.com/ponyorange/…
orangehome-user-service 单仓 简版SSO认证 github.com/ponyorange/…

系统体验

欢迎访问在线体验地址:http://8.148.251.221:6010/platform


项目启动:从想法到方案

项目启动阶段主要包含三个关键步骤:明确需求输出技术方案制作UI

明确需求

如果你的项目已经有明确的产品需求文档(PRD),可以直接跳过此步。

由于我是从零开始构思这个系统,只有模糊的想法,没有现成的产品文档。因此,我需要借助AI工具来输出一份完整、清晰的需求文档,为后续的开发奠定基础,确保我和AI都对“要做什么”有完全一致的理解。

这里我选择的是 DeepSeek Chat。原因有二:第一,免费;第二,其深度思考能力在自然语言理解领域表现优异,非常适合进行需求探讨。

操作流程:

  1. 让AI扮演资深产品经理:使用精心设计的提示词模板,与DeepSeek进行多轮对话,逐步完善需求。
**提示词示例:**  

请扮演一位资深产品经理,协助我完成一份标准的产品需求文档(PRD)。我将提供产品背景信息,请按照以下结构生成初稿,使用Markdown格式。

【产品名称】[填写产品名称]  
【目标用户】[描述主要用户画像]  
【核心痛点】[列出用户当前面临的问题]  
【核心想法】[描述你的产品功能想法,可零散列出]  
【业务目标】[可选,如提升转化率、降低客诉等量化指标]

请按以下章节输出:

1.  文档修订记录(版本、日期、作者、变更说明)
2.  项目背景与目标(包含背景、业务目标、衡量指标OKR/KPI)
3.  用户角色与用户故事(至少2个典型角色,每个角色对应2-3个用户故事)
4.  功能范围(按P0/P1/P2优先级分类,用表格形式)
5.  业务流程图(用Mermaid语法描述主流程)
6.  功能详情(重点描述P0功能,每个功能包含:功能描述、前置条件、正常流程、异常流程、字段定义)
7.  非功能性需求(性能、安全、兼容性、可扩展性等)
8.  数据埋点需求(建议列出需要追踪的关键事件)
9.  附录与术语表

要求:

-   逻辑清晰,避免歧义
-   异常流程至少覆盖3种常见边界情况
-   验收标准需明确可量化

  1. 多角色审查(可选,更严谨) :在第一版PRD生成后,可以让AI分别扮演后端开发、前端开发和测试工程师的角色,对PRD进行交叉审查,找出潜在问题并提出修改建议。
**提示词示例:**  

请分别扮演【后端开发工程师】、【前端开发工程师】和【测试工程师】三个角色,对以下PRD内容进行审查。我将粘贴具体章节,请从每个角色的角度指出问题,并给出修改建议。

审查重点:

-   后端开发:关注数据一致性、接口设计、性能瓶颈、安全风险、扩展性
-   前端开发:关注交互细节、状态管理、异常反馈、兼容性、用户体验
-   测试工程师:关注逻辑完整性、边界条件、测试点覆盖、可测性

请按以下格式输出:  
【后端开发视角】

-   问题点1:描述问题 → 建议修改
-   问题点2:…

【前端开发视角】  
…

【测试工程师视角】  
…

最后,请总结你认为最重要的3个待优化项。
  1. 产出:根据反馈再次完善,最终得到一份高质量的系统需求文档和各子系统的详细需求文档。

个人体会:AI在这里产出的需求文档,其结构清晰度和逻辑严谨性,已经超越了绝大多数我见过的PM所写的PRD。强烈推荐大家尝试一下。

技术方案

核心原则:你负责架构设计和技术选型,AI负责完善技术细节。

为什么不直接把需求文档丢给AI,让它全权负责技术方案呢?难道AI的架构能力不强吗?

并非如此。我甚至相信AI在这方面的能力远超大多数高级工程师。但问题在于,如果你不能理解和驾驭AI的设计,你就无法掌控整个项目。项目初期一切顺利,但到了后期进行功能扩展或Bug修复时,代码可能会变得一团糟,让你怀疑AI的能力。这不是AI不行,而是你没有驾驭它。就像一个不会开车的人,出了车祸只会责怪车不好。

当然,如果你完全没有头绪,完全可以与AI探讨。你可以拿着AI提供的技术方案,追问它为什么这样设计,有什么瓶颈,有没有其他替代方案。这个过程本身就是向AI学习、最终在思想上超越AI的过程。只有这样,你才能真正地领导AI,而不是被AI领导。AI的价值在于降低了学习成本,帮你快速答疑解惑。

因此,在AI时代,软件工程师的核心价值在于架构审美能力、业务价值判断能力和独立思考能力。如果你盲目相信AI,那么你的上限就是AI的上限。而如果你能保持独立思考,不断质疑AI的回答,你将永远凌驾于AI之上。

我的架构设计思路

  • 为什么拆分user-service? 提供统一的认证能力,相当于一个简版的单点登录(SSO)服务,解耦用户管理与核心业务。
  • 为什么拆分core-service? 封装低代码搭建的所有通用能力(如Schema解析、组件渲染引擎等),让上层的BFF和前端应用无需关心复杂的内部实现细节。
  • 编辑器如何设计? 采用轻内核、高扩展的插件化架构。内核只负责管理生命周期和全局状态,其余所有功能(如拖拽、图层、属性面板)都通过插件实现,确保核心稳定,功能灵活。
  • 这里不过多探讨,本文主要侧重于ai研发工作流。

技术选型思考

  • 后端语言:Go vs Node.js? Go在高并发、性能和内存占用上有先天优势。但本项目的重点在于探索AI能力,而非追求极致性能。同时,为了确保我能更好地驾驭AI,我选择了我更熟悉的Node.js生态。
  • 框架:NestJS vs Koa? API网关、简单的接口服务可以用Koa。但对于这个中大型、业务逻辑复杂的系统,NestJS的模块化、依赖注入和面向切面编程(AOP)能力更胜一筹,因此选择NestJS。
  • 数据库:MongoDB vs PostgreSQL? 核心业务数据(如用户、项目、页面)结构稳定、关联性强,选择PostgreSQL。而物料、Schema等更倾向于文档形态的数据,结构可能频繁变化,选择MongoDB。
  • Monorepo工具:Turbo vs Rush? 超大型企业级项目、对依赖管理有极致要求的选择Rush。我的主应用层虽然复杂,但更追求现代化的构建速度和简洁的配置体验,因此选择了Turbo Repo。
  • 状态管理:Redux vs Zustand? 除非是跨框架/跨应用的超大全局状态,否则绝大多数场景下,Zustand配合SWR就能以更少的代码量完美覆盖。因此,我们选择Zustand。

以上这些选型,你完全可以借助AI作为“高级XX工程师”来获得选型建议和论证。在这个过程中,你不仅能做出决策,更能学到背后的权衡逻辑,但前提是你必须保持思考,而不是一味采纳

核心数据设计

  • 数据表设计:我先根据需求设计出第一版,再让AI帮我完善字段、索引和关联关系。
  • 数据协议(Schema)设计:这是整个低代码系统的核心。整个系统的价值,就在于编辑器能否方便快捷地输出这份Schema,而C端能否高性能、高还原度地渲染这份Schema。

我设计的核心Schema结构如下: ‍‍

{ 
    "id": "comp_id_xx",
    "type": "VideoPlayer", 
    "props": {}, // 组件属性 
    "actions": {}, // 组件事件,支持一些JS纯函数的执行 
    "style": {}, // 组件样式,主要控制布局和整体风格 
    "elementStyles": { // 复杂组件内部元素的样式配置 
        "playButton": {} // 例如,播放按钮的样式 
     } 
 } ‍‍‍

在AI时代,Schema的生成不再高度依赖拖拉拽,完全可以交给AI来完成。因此,在Schema设计上,我们要考虑让AI更容易理解,例如使用更语义化的字段名。未来,90%的低代码搭建操作将由AI完成,拖拉拽将退化为高级功能,仅用于人工微调细节。

技术方案落地

当核心设计完成后,就可以借助AI生成完整的、可落地的技术方案。

**提示词示例:**  

请基于以下需求和技术背景,为我生成一份完整、可落地的后端技术方案。请以资深后端架构师的视角输出,使用Markdown格式,内容需具体、可执行。

【需求摘要】  
(粘贴PRD中的核心功能、业务流程、非功能性需求摘要,或用简洁语言描述)

【技术选型与框架】

-   开发语言/框架:[例如 Java 17 + Spring Boot 3.x]
-   数据库:[例如 MySQL 8.0 + MyBatis-Plus]
-   缓存:[例如 Redis 7.x]
-   消息队列:[例如 RocketMQ / Kafka]
-   部署环境:[例如 Kubernetes / 阿里云ECS]
-   其他中间件:[例如 Elasticsearch、MinIO]

【约束与要求】

-   性能指标:[如 QPS ≥ 2000,接口响应时间 ≤ 200ms]
-   数据一致性:[如最终一致性/强一致性]
-   安全要求:[如接口防刷、敏感数据加密]
-   已有系统集成:[说明需要与哪些内部系统对接]

请按以下章节输出详细技术方案:

1.  **总体架构设计**

    -   系统架构图(使用Mermaid语法绘制分层架构或微服务拓扑)
    -   模块划分及职责说明(核心模块、支撑模块)
    -   关键技术选型理由(简要说明为什么选择这些技术)

1.  **核心模块详细设计**

    -   针对【列出2-3个核心业务模块】分别描述:

        -   模块内部类/组件职责
        -   核心流程时序图(Mermaid)
        -   关键业务逻辑处理步骤

1.  **API接口设计**

    -   接口风格:[RESTful / GraphQL / gRPC]
    -   统一响应结构示例
    -   核心接口列表(至少覆盖主要功能,包含:接口路径、方法、请求参数、响应示例、权限要求、限流策略)

1.  **数据库设计**

    -   ER图(Mermaid erDiagram)
    -   核心表结构(表名、字段、类型、索引、说明,至少5张核心表)
    -   数据分库分表策略(如需要)
    -   数据归档与清理策略

1.  **关键技术方案**

    -   缓存设计(哪些数据缓存、更新策略、穿透/雪崩应对)
    -   消息队列应用场景(解耦、削峰、异步处理的具体场景)
    -   分布式事务方案(如适用)
    -   定时任务/调度设计
    -   文件/对象存储方案

1.  **非功能性设计**

    -   高可用设计(多活/主备、故障转移)
    -   可扩展性设计(水平扩展、微服务拆分原则)
    -   安全性设计(认证授权、数据加密、防攻击)
    -   监控与告警(关键指标、日志规范)

1.  **部署与运维**

    -   容器化与编排(Dockerfile要点、K8s部署清单概览)
    -   环境配置管理(环境变量、配置中心)
    -   发布策略(灰度发布、回滚机制)

1.  **风险评估与应对**

    -   技术风险(如性能瓶颈、依赖单点)
    -   应对措施

要求:

-   每个设计决策需附带简短的“为什么”说明。
-   数据库索引必须明确,避免全表扫描。
-   API示例需包含错误码设计。
-   如涉及外部依赖,需说明集成方式。

  • 产出:一份完整系统的技术方案和每个子系统的详细技术方案。一份整体的技术方案能让AI时刻明晰当前开发的模块在整个系统中的角色和位置,从而保证产出的代码更符合整体设计。

UI设计

为了保证系统整体风格一致,纯粹的“自然语言”控制UI细节是困难的,因此UI设计环节必不可少。

  1. 确定设计语言:首先确定一个主题色,然后让AI基于此生成一套完整、规范的配色方案,奠定整体设计语言。
  2. 生成原型:使用画板绘制草图,大致说明模块布局,并辅以自然语言描述。然后将这些输入给Cursor,让它生成一个完整的HTML文件。可以多尝试几次,从中挑选最满意的一个作为UI参考。
  3. 指导开发:在后续系统实现时,将这个HTML文件作为UI参考提供给Cursor,它能生成高度一致的真实页面,确保UI的完美呈现。

需求实现:AI Coding的战场

下面介绍我在这场实战中使用的AI编程工具和核心工作流。

  • Cursor:订阅了Pro版,20美元/月,但因为用超了(主要是GPT和Opus模型成本较高),当月总花费约50美元(约合人民币350元)。
  • OpenClaw:在Coze平台搭建,每月49元,获得40万积分。积分消耗也很快,主要使用的是Kimi 2.5模型。

一句话总结我的分工策略:Cursor + Speckit 负责开发后端服务和编辑器(复杂度高);OpenClaw + GSD 负责开发管理端(相对简单)和物料库。

Cursor + Speckit 工作流

Speckit是一个强大的工具,能将复杂的开发流程转化为结构化的指令,非常适合与Cursor协同工作。如果你的预算充足,推荐使用Opus 4.6或GPT 5.4模型;若预算有限,Kimi 2.5也是不错的选择。

  1. 初始化项目:首先安装Speckit,并在项目根目录执行初始化命令。
    ‍‍‍bash specify init . --here --ai cursor-agent --force ‍‍‍

  2. 制定项目宪章:通过 /speckit.constitution 定义项目的核心原则、编码规范和架构约束。

    重点:在此说明本服务在整个系统中的地位,例如“这是一个低代码搭建系统的核心服务,整体架构是……,本服务的职责是……”。

  3. 功能规格:使用 /speckit.specify 粘贴需求文档,并附上UI文件路径及对应的模块信息,明确要“做什么”。

  4. 澄清模糊点:使用 /speckit.clarify 让AI指出需求中不明确的地方,并一一作答,确保需求无歧义。

  5. 生成技术方案:使用 /speckit.plan 粘贴我们之前准备好的技术方案,让AI生成可执行的计划。

  6. 拆解任务:使用 /speckit.tasks 让AI自动将计划拆解为可执行、可排序的任务列表。

  7. 一致性分析(复杂项目推荐) :对于编辑器这类复杂项目,可以使用 /speckit.analyze 检查需求、计划和任务是否对齐。

  8. 编码实现:最后,使用 /speckit.implement 让AI按照计划和任务列表开始编码。

后续功能迭代流程

所有变更,坚持“先改Spec,再改代码”的原则:

  1. /speckit.specify:追加新功能、设计稿或验收标准。
  2. /speckit.clarify:自动检查新需求是否有歧义。
  3. /speckit.plan:自动生成或手动描述具体实现方案。
  4. /speckit.tasks:自动规划新任务的实施步骤。
  5. /speckit.implement:实现新功能。

OpenClaw + GSD 工作流

将你的GitHub用户名和临时Token提供给OpenClaw,它就能帮你创建仓库、推送和拉取代码。

  1. 对齐规范:向OpenClaw明确开发规范。例如,每次开发都必须从主分支拉取新分支,完成后生成Pull Request (PR)链接。为此,我制作了一个GitHub开发规范的Skill,可以共享给多个OpenClaw实例使用。
  2. 准备依赖数据:确保OpenClaw能访问必要的接口文档。如果后端服务在本地,无法公网访问,可以导出一份接口文档发给它。要求OpenClaw在开发时严格依据文档进行Mock,并提供一个开关,方便后续切换到真实数据进行联调。只要文档准确,联调通常非常顺利。
  3. 安装GSD:让OpenClaw安装GSD(get-shit-done)工具,用于任务管理。
  4. 分发任务:将需求文档和技术方案发给OpenClaw,并用GSD生成具体的执行任务列表。
  5. 编码实现:让OpenClaw严格按照任务列表,一步一步编码实现。
  6. 实时预览:在开发过程中,可以要求OpenClaw打开浏览器,让你实时查看页面效果,及时反馈调整。
  7. 代码审查:开发完成后,OpenClaw会将PR链接发给你,进行代码审查和合并,完成闭环。

总结与展望

以上就是近期对AI Coding在复杂项目中应用的探索与实践。从项目启动、技术设计到编码实现,AI已经深度参与其中,并展现了惊人的效率。在不久的将来,开发范式将是 “人负责架构审美和业务价值的判断,AI负责实现与执行。” 所谓的母语编程时代即将到来!

在后续的系列文章中,我将继续探索AI在项目全生命周期中其他环节的应用,例如:

  • 端到端测试:特别是复杂Bug的修复。
  • 项目部署与运维:实现自动化的CI/CD和智能监控。
  • AI在业务中的赋能:例如开发一个“页面搭建智能体”或“营销推广智能体”,让AI直接服务于最终业务。

欢迎大家一起交流学习,共同探索AI时代的研发新范式。

(标题说的7天,是按每天8小时工时算的大概7天,实际上是利用下班时间零碎搞了差不多一个月)

JavaScript 实现图片懒加载

2026年3月30日 23:26

文档概述

本文基于原生 JavaScript IntersectionObserver API 实现的图片懒加载方案知识库说明,通过监听元素与视口的交叉状态,仅在图片进入视口时加载真实图片,减少初始页面加载资源消耗,提升页面性能与用户体验。

核心特性

  1. 原生 API 实现:基于 IntersectionObserver,无需引入第三方库,兼容性良好
  2. 性能优化:使用 DocumentFragment 批量操作 DOM,减少重绘重排
  3. 占位图策略:初始加载默认占位图,进入视口后替换为真实图片
  4. 自动停止观察:图片加载完成后自动取消观察,避免重复触发
  5. 视觉交互:包含卡片悬停缩放效果、自定义滚动条样式,提升用户体验

依赖说明

  • 核心技术:原生 JavaScript(ES6+)、HTML5、CSS3

  • 浏览器兼容性:支持 IntersectionObserver API 的现代浏览器(Chrome 51+、Firefox 55+、Safari 12.1+、Edge 79+)

  • 外部资源:

    • 默认占位图:https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg
    • 动态图片模板:https://picsum.photos/400/600?r={index}

完整代码实现

1. HTML 结构

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <title>JavaScript Observer 实现懒加载</title>
  <link rel="stylesheet" href="./css/index.css" />
</head>
<body>
  <div class="card-list"></div>
  <script src="./js/index.js"></script>
</body>
</html>

2. CSS 样式

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  background-color: #f5f6f7;
}

.card-list {
  --ap-gap: 16px;
  --ap-min-width: 300px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(var(--ap-min-width), 1fr));
  gap: var(--ap-gap);
  padding: 16px;
}

.card-list .item {
  cursor: pointer;
  height: 497px;
  border-radius: 10px;
  box-shadow: 0 0 6px #000;
  overflow: hidden;
}

.card-list .item:hover img {
  transform: scale(1.5);
}

.card-list .item img {
  display: block;
  width: 100%;
  height: 100%;
  transition: all 0.32s;
}

/* 自定义滚动条样式 */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}
::-webkit-scrollbar-track {
  background-color: #f5f5f5;
}
::-webkit-scrollbar-track-piece {
  border-radius: 6px;
  background-color: #f5f5f5;
}
::-webkit-scrollbar-thumb {
  border-radius: 6px;
  background-color: #ccc;
}
::-webkit-scrollbar-thumb:hover {
  background-color: #a8a8a8;
}
::-webkit-scrollbar-thumb:active {
  background-color: #787878;
}
::-webkit-scrollbar-corner {
  background-color: #f5f5f5;
}
::-webkit-resizer {
  background-repeat: no-repeat;
  background-position: bottom right;
}

3. JavaScript 逻辑

// 配置项
const TOTAL_ITEMS = 99; // 总图片数量
const DEFAULT_IMG = 'https://pica.zhimg.com/v2-f052aa50ca65df4bad1c3b7e4084d00e_1440w.jpg'; // 默认占位图
const IMG_URL_TEMPLATE = (index) => `https://picsum.photos/400/600?r=${index}`; // 动态图片模板

const cardList = document.querySelector('.card-list');

/**
 * 生成图片卡片
 * 使用 DocumentFragment 批量操作 DOM,减少重绘重排
 */
function generateItems() {
  const fragment = document.createDocumentFragment();
  for (let i = 0; i < TOTAL_ITEMS; i++) {
    const div = document.createElement('div');
    div.classList.add('item');
    
    const img = document.createElement('img');
    img.src = DEFAULT_IMG; // 初始设置占位图
    img.dataset.src = IMG_URL_TEMPLATE(i); // 真实图片地址存储在 data-src
    img.alt = `Image ${i + 1}`;
    
    div.appendChild(img);
    fragment.appendChild(div);
  }
  cardList.appendChild(fragment); // 一次性插入所有元素
}

/**
 * 初始化 Intersection Observer
 * 监听图片与视口的交叉状态,实现懒加载
 */
function initLazyLoad() {
  const observer = new IntersectionObserver(
    (entries, observer) => {
      entries.forEach((entry) => {
        if (!entry.isIntersecting) return; // 跳过未进入视口的元素
        
        const img = entry.target;
        img.src = img.dataset.src; // 替换为真实图片
        observer.unobserve(img); // 加载完成后停止观察
      });
    },
    {
      threshold: 0.01, // 交叉阈值:元素 1% 进入视口时触发
    }
  );

  // 观察所有带有 data-src 属性的图片
  document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
}

// 执行主逻辑
generateItems();
initLazyLoad();

核心实现说明

1. 图片卡片生成

  • DocumentFragment 优化:使用文档片段暂存所有卡片元素,最后一次性插入 DOM,避免多次操作 DOM 导致的性能损耗
  • 占位图策略:初始 img.src 设置为默认占位图,真实图片地址存储在 data-src 自定义属性中
  • 动态图片地址:通过模板函数 IMG_URL_TEMPLATE 生成唯一的动态图片地址,避免缓存

2. Intersection Observer 初始化

  • 交叉阈值(threshold) :设置为 0.01,即图片 1% 进入视口时触发加载,提前加载提升流畅度
  • 回调处理:遍历所有观察项,仅处理进入视口的元素(entry.isIntersectingtrue
  • 自动停止观察:图片加载完成后调用 observer.unobserve(img),避免重复触发回调,节省性能

API 说明

IntersectionObserver 配置

配置项 类型 默认值 说明
threshold number 0 元素与视口的交叉比例阈值,达到该比例时触发回调
root Element null 观察的根元素,默认为视口
rootMargin string '0px' 根元素的外边距,用于提前或延迟触发

核心方法

方法名 说明
observe(target) 开始观察目标元素
unobserve(target) 停止观察目标元素
disconnect() 停止观察所有元素

使用示例

直接在浏览器中打开 HTML 文件即可体验:

  1. 页面初始加载 99 个带占位图的卡片
  2. 滚动页面,卡片进入视口时自动加载真实图片
  3. 鼠标悬停卡片,图片放大 1.5 倍
  4. 滚动条样式自定义,提升视觉体验

注意事项

  1. 浏览器兼容性IntersectionObserver 在 IE 及旧版本浏览器中不支持,如需兼容可引入 polyfill(如 intersection-observer
  2. 占位图优化:建议使用尺寸较小的占位图,减少初始加载资源
  3. 图片地址有效性:确保 data-src 存储的真实图片地址可访问
  4. 性能扩展:可结合 loading="lazy" 属性(原生懒加载)作为降级方案
  5. 错误处理:可添加图片加载失败的回调,替换为错误占位图
  6. 阈值调整:可根据实际需求调整 threshold 值,平衡加载时机与性能
❌
❌