阅读视图

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

零 JavaScript 的性能优化视频嵌入

原文:Performance-Optimized Video Embeds with Zero JavaScript

翻译:TUARAN

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

image.png

嵌入视频往往会显著拖慢页面:播放器会加载一堆额外资源,即使用户最终根本不点播放。

常见的优化是用 lite-youtube-embed 之类的轻量组件先占位、再按需加载。但如果视频就在首屏(above the fold),仍然可能因为占位与真实播放器尺寸/渲染时机问题带来 CLS(累计布局偏移)。

这篇文章给出一种“极简但很实用”的模式:只用原生 HTML 的 <details> / <summary> + 一点 CSS,实现交互时才加载 iframe,并且不写一行 JS。

解决方案:用 <details> / <summary> 作为交互边界

<summary> 的默认行为类似按钮:点击会展开对应 <details>,浏览器会给 <details> 加上 open 属性;再点一次就收起。

页面初始加载时,<details> 内除了 <summary> 以外的内容默认不显示——这使它天然适合“用户交互后才呈现”的内容(比如 iframe 视频)。

懒加载:要避免“首屏懒加载反伤”

现代浏览器支持 loading="lazy" 对图片与 iframe 做原生懒加载。

但需要注意:把所有东西都懒加载,可能反而让 LCP 变差。Chrome 团队的研究提到,过度懒加载可能让 LCP 下降约 20%,尤其是当你把内容懒加载到首屏视口里时。

这里的关键点在于:iframe 视频作为 <details> 的内容,在用户点击之前并不算“初始视口内容”,所以不会触发那种“首屏懒加载带来的反效果”。

结论:如果你本来就把视频放在一个可折叠区域里(accordion),那就非常适合把它延迟到“用户想看”的那一刻才加载。

样式:把 <summary> 做成视频缩略图

默认的 <details> 样式很朴素。我们可以把 <summary> 做成一个“视频缩略图占位”,上面叠一个自定义播放按钮。

<details class="video-embed">
  <summary class="video-summary" aria-label="播放视频:Big Buck Bunny">
    <img
      src="https://lab.n8d.studio/htwoo/htwoo-core/images/videos/big-bug-bunny.webp"
      class="video-thumbnail"
      alt=""
    />
    <svg class="video-playicon" viewBox="0 0 32 32" aria-hidden="true">
      <path d="m11.167 5.608 16.278 8.47a2.169 2.169 0 0 1 .011 3.838l-.012.006-16.278 8.47a2.167 2.167 0 0 1-3.167-1.922V7.529a2.167 2.167 0 0 1 3.047-1.981l-.014-.005.134.065z" />
    </svg>
  </summary>

  <div class="video-content">
    <!-- 原始 embed 代码尽量不改,直接放进来 -->
    <iframe
      src="https://www.youtube.com/embed/aqz-KE-bpKQ?autoplay=1"
      title="Big Buck Bunny"
      loading="lazy"
      allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
      allowfullscreen
    ></iframe>
  </div>
</details>

要点:

  • 缩略图与 iframe 维持同一宽高比(避免布局跳动)。
  • 播放按钮用自有 SVG,保证品牌一致性。
  • aria-label 给屏幕阅读器一个明确的动作提示(作者也强调需要做跨 VoiceOver/NVDA/JAWS 的实际测试)。

CSS 可以用 grid 把按钮叠在缩略图正中:

.video-summary {
  display: grid;
  place-items: center;
}

.video-thumbnail,
.video-playicon {
  grid-area1 / 1;
}

.video-playicon {
  width64px;
  height64px;
}

展开后隐藏缩略图,让 iframe 出现

<summary> 默认即使展开也会持续可见;但我们展开后希望看到的是 iframe,而不是缩略图。

思路很简单:当 <details> 具备 open 属性时,把 summary 隐藏。

.video-embed {
  position: relative;
}

.video-embed[open] .video-summary {
  visibility: hidden;
}

.video-content iframe {
  width100%;
  height100%;
}

用户点击缩略图时:

  • 浏览器把 open 加到 <details>
  • summary 被隐藏
  • iframe 进入视口并开始加载(而且只在用户真的想看时才加载)

小提示:对于 YouTube,可以在 iframe URL 上加 ?autoplay=1,让播放器尽快开始播放;但如果用户浏览器禁用了 autoplay,仍需要再次点击。

性能对比(与 lite-youtube-embed)

作者用同一张缩略图对比了本方案与 lite-youtube-embed

指标 <details> 模式 lite-youtube-embed 更优
Load Time 595ms 693ms <details>(约快 14%)
FCP 11ms 70ms <details>(约快 6.4×)
LCP 97ms 157ms <details>(约快 1.6×)
Transfer 34 KB 84 KB <details>(约少 2.5×)
CLS 0.0075 0.0000 都不错
TBT 0ms 0ms 持平
JavaScript 0 ~3KB <details>

(原文还提到资源请求数量也显著更少。)

收尾

  • <details> 自 2011 起就在浏览器中可用
  • iframe 原生 lazy loading 大约在 2019 落地

把两者结合起来,你就能获得“首屏更快、重内容延后、交互自然、键盘可用”的视频嵌入体验,而且完全不依赖 JavaScript。

它不是一个“产品”,而是一个“模式”:同样适用于 Vimeo、自托管视频、GIF、CodePen、地图等任何重量级嵌入内容。

gsap 配置解读 --1

toggleActions: "play none none reverse" 是什么意思

gsap.to(panel, {
y: 0,
opacity: 1,
duration: 0.8,
ease: "power2.out", 
scrollTrigger: {
trigger: panel, 
start: "top 80%", // 当 panel 的顶部到达 viewport 的 80% 位置时,进入触发区
end: "top 40%", // 当 panel 的顶部到达 viewport 的 40% 位置时,离开触发区 
toggleActions: "play none none reverse"
} 
});
位置 触发时机 说明
1. onEnter 元素从上往下滚动进入触发区间(比如进入 startend 区域) 此处是 "play" → 播放动画
2. onLeave 元素继续向下滚动,离开触发区间(滚出 end 之后) 此处是 "none" → 什么都不做
3. onEnterBack 元素从下往上滚动,重新进入触发区间(反向滚动进入) 此处是 "none" → 什么都不做
4. onLeaveBack 元素继续向上滚动,离开触发区间(反向滚出 start 之前) 此处是 "reverse" → 反向播放动画(即倒放)

toggleActions

动作值 效果
"play" 播放动画(从当前进度开始)
"pause" 暂停动画
"resume" 恢复播放(如果已暂停)
"reverse" 反向播放(倒放)
"restart" 从头开始播放
"reset" 重置到初始状态
"none" 无操作(保持当前状态)

 典型使用场景对比:

需求 推荐 toggleActions
进入播放,离开重置 "play none none reset"
进入播放,反向离开时倒放 "play none none reverse" ← 你的情况
只播放一次,之后不再动 "play pause pause pause"
来回都播放 "play play play play"(不推荐,会闪烁)

paused: true是什么意思

{
x: 280, 
scale: 0.5,
opacity: 0,
duration: 1,
ease: "power2.out",
paused: true 
});

在 GSAP(GreenSock Animation Platform)中,paused: true 是一个动画配置选项,它的作用是:

创建动画时立即暂停(不自动播放),等待后续手动控制播放。

  • gsap.from(...) 表示:从指定的起始状态(x=280, scale=0.5, opacity=0)动画到元素当前的 CSS 状态
  • 但由于设置了 paused: true,这个动画不会立刻执行,而是被“冻结”在初始状态(即元素保持原样,不会动)。
  • 你需要手动调用 tween.play() 才会开始播放动画。

✅ 为什么需要 paused: true

通常用于以下场景:

1. 延迟触发动画

比如点击按钮、滚动到某位置、或满足某个条件后再播放:

document.querySelector('#btn').addEventListener('click', () => { 
tween.play(); // 点击时才播放
});
2. 配合 ScrollTrigger 或其他交互逻辑

你可能先定义好动画,等 ScrollTrigger 初始化完成后再关联:

ScrollTrigger.create({
trigger: ".section",
start: "top center",
onEnter: () => tween.play()
});
3. 复用动画

同一个 tween 可以多次 play()reverse()restart(),而不会重复创建。

方法 作用
tween.play() 播放动画
tween.pause() 暂停动画
tween.reverse() 反向播放(从当前进度倒放回起点)
tween.restart() 从头开始播放
tween.seek(0.5) 跳转到动画 50% 进度
tween.progress(1) 瞬间跳到结束状态
配置 含义
paused: true 创建动画但不自动播放,需手动调用 .play() 等方法控制
默认(不写) 动画创建后立即自动播放

gsap.set() set 是什么意思

在 GSAP(GreenSock Animation Platform)中,gsap.set() 是一个立即设置元素属性的方法,不会产生动画过渡,而是瞬间应用指定的样式或属性值

方法 是否动画 用途
gsap.set(target, vars) ❌ 否 立即设置属性(相当于“初始化状态”)
gsap.to(target, vars) ✅ 是 从当前状态 动画到 指定状态
gsap.from(target, vars) ✅ 是 从指定状态 动画到 当前状态
gsap.fromTo(target, fromVars, toVars) ✅ 是 自定义起始和结束状态

clearProps: "all"是什么意思

gsap.set([boxA, boxB], { clearProps: "all" }); 这行代码的作用是:

立即清除 boxA 和 boxB 元素上由 GSAP 设置的所有内联样式属性(比如 transformopacitybackgroundColor 等),让它们恢复到 GSAP 干预之前的状态(即仅受 CSS 类或原始 HTML 样式控制)。


✅ clearProps 的作用详解

  • GSAP 在执行动画(如 gsap.to()gsap.from())或 gsap.set() 时,会直接写入元素的 style 属性(例如:<div style="transform: translateX(100px); opacity: 0.5;">)。
  • 这些内联样式优先级很高,会覆盖你写的 CSS 类。
  • 使用 clearProps 可以清理这些“残留”的内联样式,避免干扰后续布局或样式。
说明
"all" 清除 所有 GSAP 设置过的内联样式(最常用)✅
"transform" 仅清除 transform 相关属性(如 x, y, scale, rotation 等)
"opacity,backgroundColor" 清除指定的多个属性(用逗号分隔)
"x,y" 仅清除 xy(即 transform: translateX/Y

💡 注意:clearProps 只清除 GSAP 显式设置过 的属性,不会影响其他 JavaScript 或 HTML 中原本就有的 style

🎯 使用场景举例

场景 1:重置动画状态

js

// 先执行一个动画
gsap.to(boxA, { x: 100, backgroundColor: "red", duration: 1 });

// 后来想让它完全回到原始 CSS 样式
gsap.set(boxA, { clearProps: "all" });
// 效果相当于:boxA.style.cssText = ""; (但更安全,只清 GSAP 设置的)
场景 2:避免 transform 冲突

css

.my-box {
  transform: rotate(10deg); /* 原始 CSS transform */
}

js

gsap.to(".my-box", { x: 50 }); // GSAP 会合并 transform,变成 rotate + translate
gsap.set(".my-box", { clearProps: "transform" }); // 清除后,只剩 rotate(10deg)
场景 3:组件销毁前清理

在 React/Vue 组件卸载时,清除 GSAP 添加的样式,防止内存泄漏或样式残留。


⚠️ 注意事项

  1. clearProps: "all" 不会删除非 GSAP 设置的内联样式
    比如你手动写了 <div style="color: blue">,GSAP 不会动它。
  2. transform 是一个整体
    即使你只设置了 x: 100clearProps: "transform" 也会清除整个 transform 字符串。
  3. autoAlpha 会同时影响 opacity 和 visibility
    如果你用了 autoAlpha,需要同时清除这两个属性。
代码 作用
gsap.set(el, { clearProps: "all" }) 彻底清除 GSAP 对该元素设置的所有内联样式,恢复“干净”状态

keyframes是什么意思

const tween = gsap.to(shape, {
        keyframes: [
          { x: -160, rotation: -15, duration: 0.4 },
          { x: 0, scale: 1.2, duration: 0.4 },
          { x: 160, rotation: 20, duration: 0.4 },
          { x: 0, scale: 1, rotation: 0, duration: 0.4 }
        ],
        ease: "power1.inOut",
        paused: true
      });

在 GSAP(GreenSock Animation Platform)中,keyframes 是一种将多个动画步骤串联起来的方式,类似于 CSS 的 @keyframes,但功能更强大、更灵活。

这段代码的意思是:

对 shape 元素执行一个由 4 个关键帧组成的复合动画,每个关键帧持续 0.4 秒,总共 1.6 秒。动画被暂停(paused: true),需手动调用 .play() 才会运行。


✅ keyframes 的工作原理

  • 每个对象代表一个动画阶段(关键帧)
  • GSAP 会按顺序依次播放这些关键帧。
  • 每一帧的属性是从上一帧的结束状态过渡到当前帧的目标值。
  • 每帧可以有自己的 durationease(如果未指定,则继承外层的 ease)。
动画流程分解:
阶段 起始状态 → 目标状态 效果
第1帧 当前状态 → {x: -160, rotation: -15} 向左飞 + 左转
第2帧 上一帧结束 → {x: 0, scale: 1.2} 回到中心 + 放大
第3帧 上一帧结束 → {x: 160, rotation: 20} 向右飞 + 右转
第4帧 上一帧结束 → {x: 0, scale: 1, rotation: 0} 回到原位 + 还原大小和角度

🔧 keyframes 的高级用法

1. 每帧可单独设置缓动(ease)

js

keyframes: [
  { x: 100, duration: 0.3, ease: "back.out" },
  { x: 0, duration: 0.3, ease: "elastic.out" }
]
2. 支持回调函数

js

keyframes: [
  { x: 100, duration: 0.5 },
  { 
    x: 0, 
    duration: 0.5,
    onComplete: () => console.log("第二帧完成") 
  }
]
3. 与 ScrollTrigger、Timeline 结合

js

gsap.timeline({
  scrollTrigger: { trigger: ".section", start: "top center" }
}).to(shape, {
  keyframes: [ /* ... */ ]
});

⚠️ 注意事项

  • keyframes 是 GSAP 3.0+  引入的功能,在旧版本中不可用。
  • 外层的 ease(如你的 "power1.inOut")会作为默认缓动应用到每一帧(除非某帧自己指定了 ease)。
  • 如果某帧没有指定 duration,它会继承前一帧的 duration 或使用默认值(通常为 0.3 秒)。

✅ 为什么用 keyframes 而不用多个 gsap.to()

表格

方式 优点
keyframes 代码更紧凑,自动串联,易于管理单个动画序列
多个 gsap.to() 更灵活(可插入延迟、回调等),适合复杂编排(推荐用 gsap.timeline()

对于简单的线性多步动画,keyframes 非常简洁;对于复杂时间轴,建议用 gsap.timeline()


keyframes = 把多个动画步骤写在一个数组里,GSAP 自动按顺序播放它们。

你的代码创建了一个“左右晃动 + 缩放”的弹性动画,常用于:

  • 按钮点击反馈
  • 错误提示抖动
  • 卡片翻转/弹跳效果

配合 paused: true,你可以在需要时(如点击、滚动)通过 tween.play() 触发动画,非常高效!

stagger 是什么意思

在 GSAP(GreenSock Animation Platform)中,stagger 是一个非常强大的功能,用于对多个目标元素(如数组、NodeList)依次错开播放动画,从而创建出“波浪式”、“逐个入场”等流畅的序列动画效果。

 const tween = gsap.from(cells, {
        opacity: 0,
        scale: 0.4,
        y: 20,
        duration: 0.6,
        ease: "power2.out",
       stagger: { 
           each: 0.04, // 每个元素之间的延迟时间(秒)
           from: "center" // 动画从中间的元素开始,向两边扩散
       },
        paused: true
      });

这段代码的作用是:

对 cells(一组 DOM 元素)执行“从透明、缩小、下移”状态淡入放大的动画,但不是同时播放,而是:

  • 从中间的元素开始
  • 相邻元素之间间隔 0.04 秒依次播放
  • 整体形成一种“由中心向外扩散”的入场效果 ✨

✅ stagger 的核心概念

当你对多个元素(如 document.querySelectorAll('.cell'))使用 GSAP 动画时:

  • 不加 stagger → 所有元素同时动画。
  • 加上 stagger → 元素依次错开动画,产生节奏感。

🔧 stagger 的常见写法

1. 最简形式:只指定间隔时间

js

stagger: 0.1  // 等价于 { each: 0.1 }

→ 从第一个元素开始,每个间隔 0.1 秒。

2. 对象形式(你用的方式):更精细控制

js

stagger: {
  each: 0.04,     // 每个元素间隔 0.04 秒
  from: "center", // 起始位置:可选 "start"(默认)、"center""end" 或具体索引(如 3)
  grid: "auto",   // 如果是网格布局,可设为 [rows, cols] 来按行/列交错
  axis: "x"       // 在网格中限制交错方向("x""y""xy")
}

🎯 from 的取值说明

效果
"start"(默认) 从第一个元素开始,依次到最后一个
"center" 从中间元素开始,向左右(或上下)同时扩散
"end" 从最后一个元素开始,倒序播放
数字(如 2) 从索引为 2 的元素开始

✅  from: "center" 非常适合居中对齐的列表、图标阵列、卡片网格等场景,视觉上更平衡。


💡 实际效果示例

假设 cells 有 5 个元素:[A, B, C, D, E]

  • from: "center" → 播放顺序:C → B & D → A & E
  • 每个间隔 0.04s,所以整个动画在约 0.04 × 2 = 0.08s 内完成扩散(因为两边并行)

这比线性播放(A→B→C→D→E)更生动!


⚠️ 注意事项

  • stagger 只在目标是多个元素时生效。如果 cells 只有一个元素,stagger 会被忽略。
  • stagger 的延迟是叠加在 duration 之上的,不影响单个动画的时长。
  • 可与 paused: true 完美配合,实现“按需触发动画序列”。

配置 含义
stagger: { each: 0.04, from: "center" } 从中间元素开始,以 0.04 秒的间隔向两侧依次播放动画

这是 GSAP 实现高级交互动效(如列表加载、菜单展开、数据可视化入场)的核心技巧之一。你的代码就是一个典型的“优雅批量入场”动画!

从 ES2015 到 ES2025:你还跟得上吗

ES6 是 2015 年发布的。
距离现在,已经过去整整十年。

这十年里,JavaScript 每一年都在进化。
新语法、新 API、新并发模型、新数据结构……

可大多数人,对 JavaScript 的认知,仍停留在:

  • 箭头函数
  • 解构赋值
  • Promise
  • let / const

从 ES2016 到 ES2025,你真的跟上了吗?

这篇文章,我会按时间线带你系统梳理 JavaScript 十年的演进轨迹。


ES2016 → ES2020

这5年新出的特性我想大多数人都已经熟练使用了,这里就简单列下,不详细介绍api细节了

ES2016

这是一个小版本,主要有以下3个特性:

  • Array.prototype.includes()
  • 指数运算符 (**)
    2 ** 3; // 8
    
  • 幂赋值运算符**=
    let num = 2; 
    num **= 3; // num = num ** 3 
    console.log(num); // 8
    

ES2017

ES2017的重点是异步编程,对象操作

  • async/await
  • Object.values()/Object.entries()
  • Object.getOwnPropertyDescriptors(): 返回对象所有自身属性的描述符对象
  • 字符串填充String Padding
console.log('5'.padStart(3, '0')); // '005' 
console.log('hello'.padEnd(10, '*')); // 'hello*****'
  • SharedArrayBuffer 和 Atomics

    这两个用在web worker中。主线程和worker使用postMessage通信往往要将数据拷贝一份,SharedArrayBuffer 则允许 Worker 线程与主线程共享同一块内存,通过 postMessage 将 SharedArrayBuffer 转移给 Worker,不会复制数据:

    // main.js
     const sab = new SharedArrayBuffer(1024);
     const worker = new Worker('worker.js');
     worker.postMessage(sab); //不复制数据
    
    // worker.js
     self.onmessage = (e) => {
       const sab = e.data; // 同一个内存块
     };
    

计算机中写操作可能被编译成多条指令,如果尚未写完就有其他线程读数据,便会产生错误。在多线程操作SharedArrayBuffer时就可能会出现这种问题。Atomics提供了原子级操作,其他线程读取到的数据,要么是没写入的,要么是已写完的。另外Atomics还提供了线程的阻塞和唤醒。

ES2018

ES2018新增了多个特性,算是一次中等规模升级,主要有异步编程的增强、对象和数组操作的改进、正则表达式的扩展,以及模板字面量的优化。

  • 异步生成器/异步迭代器
async function* fetchPages() {
  let page = 1;
  while (page <= 3) {
    const response = await fetch(`https://api.example.com/page/${page}`);
    yield await response.json();
    page++;
  }
}

(async () => {
  for await (const data of fetchPages()) {
    console.log(data);
  }
})();
  • Promise.prototype.finally()
  • rest/spreat操作符...
  • 正则表达式增强(后行断言,命名捕获等)
  • 模板字符串的标签模板提供raw访问原始字符串
function tag(strings) { 
    return strings.raw[0]; // 访问原始字符串,包括非法转义,比如LaTeX语法
} 
console.log(tag`\u{00000042}`); // strings[0]是'B' strings.raw[0]为\u{00000042}

ES2019

该版本内容不多但很实用

  • Array.prototype.flat() / flatMap()
  • Object.fromEntries()
  • String.trimStart() / trimEnd()
  • Optional catch binding: catch 可省略错误参数
try {
  JSON.parse('invalid json');
} catch {
  console.log('Parsing failed'); // 无需未使用的 error 变量
}
  • Symbol.description
  • Function.prototype.toString()能返回函数精确源码,包括注释和空格,方便调试
  • 稳定的 Array.prototype.sort()

ES2020

这个版本也是一个里程碑,更新了大量内容,而且都很实用

  • BigInt
  • Dynamic Import import()
  • 空值合并运算符??
  • 可选链运算符?.
  • Promise.allSettled()
  • String.prototype.matchAll()
  • 标准全局对象globalThis
  • 模块命名空间导出(export * as ns from 'mod')
  • for-in 枚举顺序与定义顺序一致

从ES2021开始新增的特性,在我日常code review中看到的越来越少了,但很多特性还是很实用的。

ES2021

String.prototype.replaceAll()

在此之前全局替换需要用正则/g标志

逻辑赋值运算符 (&&=, ||=, ??=)

let x = 0;
x ||= 10; // x = 10(因为 0 是 falsy)

let y = 5;
y &&= 20; // y = 20(因为 5 是 truthy)

let z;
z ??= 'default'; // z = 'default'(因为 undefined 是 nullish)

数字分隔符(1_000_000)

允许在数字字面量中使用下划线(_)作为分隔符,提高大数字的可读性。

Promise.any()

返回一个 Promise,在任意一个输入 Promise resolved 时解决;如果所有 rejected,则返回 AggregateError。

  • 语法:Promise.any(iterable)

    • iterable:Promise 数组或其他可迭代对象。
  • 示例

    const promises = [
      Promise.reject('Error 1'),
      Promise.resolve('Success'),
      Promise.reject('Error 2')
    ];
    Promise.any(promises)
      .then(value => console.log(value)) // 'Success'(第一个 resolved)
      .catch(error => console.error(error)); // 如果全 reject:AggregateError
    

WeakRefs和FinalizersRegistry:

  • WeakRefs 用于引用对象而不阻止垃圾回收
  • FinalizersRegistry 用于缓存或观察对象,而不干扰内存管理;FinalizationRegistry 提供清理回调,但不保证时序。
let obj = { name: 'Alice' };
const weak = new WeakRef(obj);

const registry = new FinalizationRegistry(heldValue => {
  console.log(`Object with ${heldValue} cleaned up`);
  console.log(weak.deref());//undefined
});
registry.register(obj, 'Alice');
obj = null; // GC 时调用 callback

ES2022

Top-level await:

可以直接在模块最外层使用 await

Class的私有/静态成员和方法

增加了#标识私有,static标识静态(现在都用ts了,这两个特性很少用到)

 class Counter {
  #value = 0;

  #increment() {
    this.#value++;
  }

  get #count() {
    return this.#value;
  }

  add() {
    this.#increment();
  }

  getValue() {
    return this.#count;
  }
}

const c = new Counter();
c.add();
console.log(c.getValue()); // 1
// c.#increment(); // SyntaxError,不过控制台访问不会报错
class MathUtils {
  static PI = 3.14159;
  static #secret = 42;

  static getPi() {
    return this.PI;
  }

  static getSecret() {
    return this.#secret;
  }
}

console.log(MathUtils.PI); // 3.14159
console.log(MathUtils.getSecret()); // 42

Error.cause:

在 Error 对象中添加 cause 属性,用于链式记录错误上下文,方便调试。

 try {
  throw new Error('Original issue');
} catch (err) {
  throw new Error('Failed operation', { cause: err });
}
// 结果错误:Failed operation (cause: Original issue)

at方法:

新增访问索引方法,可用于数组、字符串和 TypedArray

const arr = [1, 2, 3];
console.log(arr.at(1)); // 2
console.log(arr.at(-1)); // 3(最后一个元素,极力推荐这种写法)
console.log('hello'.at(-2)); // 'l'

Object.hasOwn():

代替Object.prototype.hasOwnProperty.call()

正则表达式的/d标志:

可用/d标志获取匹配范围

    const match = 'hello world'.matchAll(/(hello)/dg);
    for (const m of match) {
      console.log(m.indices[0]); // [0, 5] 对于 'hello'
    }

ES2023

Array 和 TypedArray的末尾查找

新增findLast() / findLastIndex()

通过拷贝修改数组(不改变原数组)

新增 toSorted() / toReversed() / toSpliced() / with()

  • 语法

    • array.toReversed():返回反转拷贝。
    • array.toSorted(compareFn):返回排序拷贝。
    • array.toSpliced(start, deleteCount, ...items):返回拼接拷贝。
    • array.with(index, value):返回替换指定索引值的拷贝。
  • 示例

    const arr = [1, 3, 2];
    console.log(arr.toSorted()); // [1, 2, 3](原 arr 不变)
    console.log(arr.toReversed()); // [2, 3, 1]
    
    console.log(arr.toSpliced(1, 1, 4)); // [1, 4, 2]
    console.log(arr.with(0, 5)); // [5, 3, 2]
    

Hashbang 语法

允许在 ECMAScript 文件开头使用 #!(shebang)注释,指示解释器执行脚本。

#!/usr/bin/env node
console.log('Hello from Node.js');

允许 Symbols 用作 WeakMap、WeakSet 的键

此前key仅支持对象


ES2024

Promise.withResolvers()

同时创建 Promise 及其 resolve 和 reject 函数,便于手动控制 Promise 的状态。

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

setTimeout(() => resolve('Success'), 1000);

promise.then(value => console.log(value)); // 'Success'

Object.groupBy() 和 Map.groupBy()

静态方法,用于根据回调函数返回的值对可迭代对象进行分组,返回一个对象(Object.groupBy)或 Map(Map.groupBy)。

  • 语法

    • Object.groupBy(iterable, callback)
    • Map.groupBy(iterable, callback)
  • 示例

    const items = [
      { name: 'apple', type: 'fruit' },
      { name: 'carrot', type: 'vegetable' },
      { name: 'banana', type: 'fruit' }
    ];
    
    const grouped = Object.groupBy(items, item => item.type);
    console.log(grouped); // { fruit: [{...}, {...}], vegetable: [{...}] }
    
    const groupedMap = Map.groupBy(items, item => item.type);
    console.log(groupedMap.get('fruit')); // [{...}, {...}]
    

正则表达式/v标志

新标志 /v 启用 Unicode 集模式,支持属性的组合、范围、否定、交集 / 并集运算

/v 标志出现前,JS 正则有 /u 标志支持基础 Unicode 属性转义(如 \p{Letter} 匹配字母),但只能匹配 “单一属性”,无法直接表达 “属性的组合 / 范围 / 否定”,而 /v 标志正是为了解决这个问题,提供扩展 Unicode 属性转义能力。

  • 语法:/pattern/v

  • 示例

    const regex = /[\p{Emoji}&&\p{Emoji_Presentation}]/v;
    console.log(regex.test('😀')); // true(Emoji)
    
    // 集操作示例
    const setDiff = /[a-z--[aeiou]]/v; // 辅音
    console.log(setDiff.test('b')); // true
    

Atomics.waitAsync()

共享内存的异步等待方法,返回一个 Promise,在共享值变化时解决。

  • 语法:Atomics.waitAsync(array, index, value, timeout)

  • 示例

    const sab = new SharedArrayBuffer(16);
    const int32 = new Int32Array(sab);
    
    const result = Atomics.waitAsync(int32, 0, 0);
    result.value.then(() => console.log('Woken up'));
    // 其他线程:Atomics.store(int32, 0, 1); Atomics.notify(int32, 0);
    

ArrayBuffer 和 SharedArrayBuffer 的resize和transfer

  • 语法

    • buffer.resize(newLength)
    • buffer.transfer(newLength):返回新缓冲区,旧的被分离。
    • 类似方法可用于 SharedArrayBuffer。
  • 示例

    const buffer = new ArrayBuffer(8, { maxByteLength: 16 });
    buffer.resize(16);
    console.log(buffer.byteLength); // 16
    
    const transferred = buffer.transfer();
    // 原 buffer 被分离,无法使用
    

String.prototype.isWellFormed() 和 toWellFormed()

这两个api用于"格式不良"字符串.

JavaScript 字符串基于 UTF-16 编码,其中单独的代理对字符(未配对的高 / 低代理项) 属于 “格式不良” 的字符串(也叫 “畸形 UTF-16 字符串”)。

高代理项范围:0xD800 - 0xDBFF

低代理项范围:0xDC00 - 0xDFFF只有高 + 低代理项配对才是合法的 UTF-16 字符,单独出现其中一个就是 “格式不良”。

   // 1. 格式良好的字符串(正常字符、合法代理对)
   const validStr1 = 'Hello 世界';
   console.log(validStr1.isWellFormed()); // true

   const validStr2 = '\uD83D\uDE00'; // 😀(合法的高+低代理对)
   console.log(validStr2.isWellFormed()); // true

   // 2. 格式不良的字符串(单独的高代理项/低代理项)
   const invalidStr1 = '\uD83D'; // 单独的高代理项(无对应低代理项)
   console.log(invalidStr1.isWellFormed()); // false

   const invalidStr2 = '测试\uDC00'; // 单独的低代理项(无对应高代理项)
   console.log(invalidStr2.isWellFormed()); // false

   // 3. 格式良好的字符串:返回原字符串副本
   const validStr = 'Hello 😀';
   console.log(validStr.toWellFormed()); // Hello 😀
   console.log(validStr.toWellFormed() === validStr); // true(内容相同,引用不同)

   // 4. 格式不良的字符串:替换未配对代理项为 �
   const invalidStr1 = '\uD83D'; // 单独高代理项
   console.log(invalidStr1.toWellFormed()); // �

ES2025

迭代器助手方法(Iterator Helpers)

引入 Iterator 全局对象及其原型方法,支持对任何可迭代对象(如 Array、Set、Map)进行函数式操作,如 map、filter 等。这些方法返回新的迭代器,支持惰性求值。

  • 语法:Iterator.from(iterable).method(callback)

    • 支持方法:map()、filter()、reduce()、take()、drop()、flatMap()、toArray()、forEach() 等。
  • 示例

    const arr = [1, 2, 3, 4];
    const evenSquares = Iterator.from(arr)
      .filter(x => x % 2 === 0)
      .map(x => x ** 2)
      .toArray();
    console.log(evenSquares); // [4, 16]
    

Set新增方法

为 Set.prototype 添加数学集合操作方法,支持集合的并集、交集、差集等。

  • 新增方法

    • set.union(other) 并集
    • set.intersection(other) 交集
    • set.difference(other) 差集
    • set.symmetricDifference(other) 对称差集(并集减交集)
    • set.isSubsetOf(other) 子集
    • set.isSupersetOf(other) 超集
    • set.isDisjointFrom(other) 不相交
  • 示例

    const setA = new Set([1, 2, 3]);
    const setB = new Set([2, 3, 4]);
    
    console.log(setA.union(setB)); // Set {1, 2, 3, 4}
    console.log(setA.intersection(setB)); // Set {2, 3}
    console.log(setA.isSubsetOf(setB)); // false
    

直接导入JSON 模块

引入导入属性(with 关键字),支持直接导入 JSON 文件作为模块。

  • 语法:import json from "./data.json" with { type: "json" };

  • 示例

    import data from "./config.json" with { type: "json" };
    console.log(data); // { key: "value" }
    

Promise.try()

一个新的静态方法,用于包装函数调用,确保返回 Promise,无论函数是否异步或抛出错误。

  • 语法:Promise.try(callback)

  • 示例

    Promise.try(() => {
      if (Math.random() > 0.5) throw new Error('Fail');
      return 'Success';
    })
      .then(result => console.log(result))
      .catch(error => console.error(error));
    

新增Float16Array

引入 Float16Array 类型数组,支持 16 位浮点数,以及 DataView 的 getFloat16/setFloat16 和 Math.f16round()。

RegExp.escape() 方法

一个静态方法,用于转义字符串,使其可安全用于正则表达式。

  • 语法:RegExp.escape(str)

  • 示例

    const userInput = 'a.b*c?';
    const regex = new RegExp(RegExp.escape(userInput), 'g');
    console.log('a.b*c?'.replace(regex, 'replaced')); // 'replaced'
    

正则表达式内联标志

  • 语法:/(?i:case-insensitive)/

  • 示例

    const regex = /(?i:hello)/;
    console.log(regex.test('HELLO')); // true(忽略大小写)
    

正则表达式重复命名捕获组

允许在正则表达式中重复使用相同的命名捕获组名称。

  • 语法:/(?<group>a)|(?<group>b)/

  • 示例

    const regex = /(?<year>\d{4})-(?<month>\d{2})|(?<year>\d{4})/(?<month>\d{2})/;
    const match = '2025-07'.match(regex);
    console.log(match.groups.year); // '2025'
    console.log(match.groups.month); // '07'
    

Vue 实战:从零实现“划词标注”与“高亮笔记”功能

在在线教育、文档阅读或博客系统中,划词标注(Highlight & Note) 是一个非常实用的功能。它允许用户像在纸质书上一样,用鼠标选中一段文字,进行高亮标记或添加读书笔记。

本文将拆解如何在 Vue 项目中实现这一功能,涵盖从底层的 Selection API 调用到 DOM 操作,再到数据状态管理的完整流程。


核心原理

实现划词标注的核心在于浏览器提供的 Selection APIRange API

  1. Selection: 代表用户当前选中的文本范围(可能跨越多个节点)。
  2. Range: 代表文档中一个连续的区域(Selection 通常包含一个 Range)。
  3. DOM 操作: 将选中的文本用一个特定样式的标签(如 <span>)包裹起来,从而实现高亮效果。

Step 1: 监听选区 (Capture Selection)

首先,我们需要在用户松开鼠标(mouseup)时捕获选区。

HTML 结构: 在内容容器上绑定 mouseup 事件。

<div class="content-container" @mouseup="handleTextSelection">
  <!-- 文章内容 -->
  <p>这是一段可以被选中的文本...</p>
</div>

JavaScript 实现

handleTextSelection() {
  // 延时保证选区状态已更新
  setTimeout(() => {
    const selection = window.getSelection();

    // 1. 基础校验:必须是 Range 类型且非空
    if (selection.toString().trim() === '' || selection.type !== 'Range' || selection.isCollapsed) {
      this.selectionMenuVisible = false; // 隐藏菜单
      return;
    }

    // 2. 获取核心 Range 对象
    const range = selection.getRangeAt(0);

    // 3. (可选) 进阶校验:禁止跨特定区域选择
    // 比如:不能同时选中 A 选项和 B 选项
    if (this.isCrossBlockSelection(range)) {
      selection.removeAllRanges();
      return;
    }

    // 4. 执行高亮包裹逻辑(见下文)
    this.createTempHighlight(range, selection);
  }, 0);
}

Step 2: 包裹文本 (Wrap Text)

获取到 Range 后,我们需要将选中的文本用一个临时标签(Temp Span)包裹起来。这个标签通常有两个作用:

  1. 视觉反馈:给用户一个“预选中”的状态(例如浅蓝色背景)。
  2. 定位锚点:用于计算后续“操作菜单”的显示位置。

核心代码

createTempHighlight(range, selection) {
  // 创建一个包裹标签
  const span = document.createElement('span');
  span.className = 'temp-selection-highlight'; // 自定义样式类

  try {
    // 核心操作:提取内容 -> 插入节点
    // range.extractContents() 会将选区内容从 DOM 树中移除并返回 DocumentFragment
    span.appendChild(range.extractContents());
    // 将包裹后的 span 插入回原处
    range.insertNode(span);

    // ⚠️重要:重置选区
    // 因为 DOM 结构改变了,原有的 selection 会失效或错位
    // 我们需要重新选中这个 span 的内容,让用户感觉“高亮还在”
    const newRange = document.createRange();
    newRange.selectNodeContents(span);
    selection.removeAllRanges();
    selection.addRange(newRange);

    // 保存当前 Range 引用,供后续操作使用
    this.currentRange = newRange;

    // 5. 显示操作菜单
    this.showActionMenu(span);

  } catch (e) {
    console.error('Wrapping failed:', e);
  }
}

Step 3: 菜单定位 (Positioning Menu)

操作菜单(“高亮”、“笔记”)通常悬浮在选区上方。我们可以利用 getBoundingClientRect()getClientRects() 来获取位置。

showActionMenu(spanElement) {
  // 获取元素的位置信息
  // getClientRects() 对于跨行文本更准确,取最后一行
  const rects = spanElement.getClientRects();
  const lastRect = rects.length > 0 ? rects[rects.length - 1] : spanElement.getBoundingClientRect();

  // 计算菜单坐标(相对于视口)
  this.selectionMenuPosition = {
    top: (lastRect.bottom + 5) + 'px', // 显示在下方 5px 处
    left: (lastRect.right + 5) + 'px'
  };

  this.selectionMenuVisible = true;
}

Step 4: 确认与状态管理 (Confirm & State)

用户点击菜单中的“高亮”或“笔记”按钮后,我们需要将临时的 span 转换为持久化的状态。

  1. 修改样式:将 temp-selection-highlight 类替换为 permanent-highlight(黄色)或 note-highlight(蓝色)。
  2. 生成 ID:给 span 添加一个唯一 ID(如 data-id="167...")。
  3. 保存数据:将笔记内容推入 Vue 的数据数组中。
applyHighlight(type) {
  const span = document.querySelector('.temp-selection-highlight');
  if (!span) return;

  // 1. 生成唯一 ID
  const id = Date.now().toString();

  // 2. 更新 DOM 类名和属性
  span.className = type === 'note' ? 'note-highlight' : 'highlight-text';
  span.setAttribute('data-id', id);

  // 3. 存入数据层
  const newNote = {
    id,
    text: span.innerText, // 选中的原文
    type, // 'highlight' or 'note'
    color: type === 'note' ? '#e6f7ff' : '#ffeb3b',
    createTime: new Date().toISOString()
  };

  this.notesList.push(newNote);

  // 4. 持久化(保存到后端或 LocalStorage)
  this.saveData();

  // 5.如果是笔记,打开侧边栏供用户输入
  if (type === 'note') {
    this.openNoteSidebar(id);
  }

  // 清除选中状态
  window.getSelection().removeAllRanges();
  this.selectionMenuVisible = false;
}

Step 5: 取消高亮 (Unwrap)

如果用户想删除高亮,我们需要执行“反向操作”:将 span 去掉,保留里面的文字。

removeHighlight(id) {
  const span = document.querySelector(`span[data-id="${id}"]`);
  if (span) {
    const parent = span.parentNode;
    // 将 span 的子节点(文本)移动到父节点中 span 的前面
    while (span.firstChild) {
      parent.insertBefore(span.firstChild, span);
    }
    // 移除空 span
    parent.removeChild(span);
    // 规范化节点,合并相邻的文本节点
    parent.normalize();
  }

  // 同步删除数据
  this.notesList = this.notesList.filter(n => n.id !== id);
}

进阶技巧:从数据还原 DOM

最大的难点在于:页面刷新后,如何重新渲染这些高亮?

如果你的内容是纯静态的,可以直接保存包含 span 标签的 HTML 字符串。但由于 Vue 的 v-html 或 React 的 dangerouslySetInnerHTML 会导致 DOM 重绘,简单的 HTML 替换可能会丢失事件绑定。

更稳健的做法是:

  1. 保存 选区路径(如:第 X 个段落,第 Y 个字符开始,长度 Z)。
  2. 页面加载时,遍历数据,利用 createRange() 重新定位并包裹 DOM。

由于这通常涉及到复杂的 DOM 遍历算法,生产环境中推荐结合成熟库(如 Rangy 或自行实现基于 XPath 的定位)来处理复杂场景。


总结

实现一个划词笔记功能,本质上是对 DOM Range 的灵活运用。通过 监听(Listen) -> 包裹(Wrap) -> 存储(Store) -> 还原(Restore) 这四个步骤,我们就能为用户提供流畅的沉浸式阅读体验。

深入解析 React 回到顶部(BackToTop)组件的实现与设计

深入解析 React 回到顶部(BackToTop)组件的实现与设计

在现代网页开发中,长页面的场景十分常见,为了提升用户体验,“回到顶部” 功能几乎成为标配。本文将基于一段 React 实现的 BackToTop 组件代码,从结构、核心逻辑、性能优化等维度,全面解析该组件的设计与实现细节。

一、组件整体结构概览

首先来看 BackToTop 组件的完整代码结构,该组件基于 React 函数式组件实现,核心依赖 React 的 Hooks、UI 组件库、图标库以及自定义的节流工具函数,整体结构清晰且模块化。

import { useEffect, useState } from "react";
import { Button } from '@/components/ui/button'
import { ArrowUp } from "lucide-react";
import { throttle } from "@/utils";

// 定义组件Props类型
interface BackToTopProps {
    // 滚动超过多少像素后显示按钮
    threshold?: number
}

// 函数式组件,设置threshold默认值为400
const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => {
    // 状态管理:控制按钮是否可见
    const [isVisible, setIsVisible] = useState<boolean>(false);
    
    // 回到顶部核心逻辑
    const scrollTop = () => {
        window.scrollTo({
            top: 0,
            behavior:'smooth'
        })
    }
    
    // 监听滚动事件,控制按钮显示/隐藏
    useEffect(() => {
        const toggleVisibility = () => {
            setIsVisible(window.scrollY > threshold);
        }
        // 节流处理滚动监听函数
        const thtottled_func = throttle(toggleVisibility,200);
        window.addEventListener('scroll', thtottled_func);
        // 清理副作用:移除滚动监听
        return () => window.removeEventListener('scroll', thtottled_func);
    },[threshold])
    
    // 条件渲染:未达到阈值时不渲染组件
    if(!isVisible) return null;
    
    // 组件UI渲染
    return (
        <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
            <ArrowUp className="h-4 w-4" />
        </Button>
    )
}

export default BackToTop

组件整体可分为 5 个核心部分:

  1. 依赖导入与类型定义;
  2. 状态管理(控制按钮可见性);
  3. 回到顶部核心逻辑;
  4. 滚动事件监听与性能优化;
  5. 条件渲染与 UI 展示。

二、核心功能逐行解析

1. 类型定义与 Props 设计

interface BackToTopProps {
    threshold?: number
}

const BackToTop: React.FC<BackToTopProps> = ({
    threshold = 400
}) => { ... }
  • 定义BackToTopProps接口,仅暴露threshold可选属性,用于配置 “滚动超过多少像素后显示按钮”,符合 “最小可用 API” 设计原则;
  • 通过解构赋值为threshold设置默认值 400,确保组件在未传入参数时仍能正常工作。

2. 状态管理:控制按钮可见性

const [isVisible, setIsVisible] = useState<boolean>(false);

使用useState Hook 创建布尔类型状态isVisible,初始值为false(页面加载时按钮默认隐藏),该状态用于控制组件的条件渲染。

3. 回到顶部逻辑:平滑滚动实现

const scrollTop = () => {
    window.scrollTo({
        top: 0,
        behavior:'smooth'
    })
}
  • 调用window.scrollTo方法实现滚动到页面顶部;
  • 通过配置behavior: 'smooth'实现平滑滚动,替代传统的瞬间跳转,提升用户体验;
  • 该函数作为按钮的点击事件回调,触发回到顶部操作。

4. 滚动监听与性能优化(核心)

useEffect(() => {
    const toggleVisibility = () => {
        setIsVisible(window.scrollY > threshold);
    }
    const thtottled_func = throttle(toggleVisibility,200);
    window.addEventListener('scroll', thtottled_func);
    return () => window.removeEventListener('scroll', thtottled_func);
},[threshold])

这是组件的核心逻辑,需重点解析:

(1)滚动监听函数toggleVisibility

toggleVisibility的作用是判断页面滚动距离(window.scrollY)是否超过阈值(threshold),并通过setIsVisible更新按钮可见状态。

(2)节流处理的必要性

scroll事件是高频触发事件(页面滚动时会连续触发),若直接将toggleVisibility绑定到scroll事件,会导致该函数被频繁调用,引发不必要的状态更新和重渲染,影响页面性能。

因此,组件通过throttle工具函数对toggleVisibility进行节流处理,设置 200ms 的节流间隔 —— 即滚动事件触发时,toggleVisibility最多每 200ms 执行一次,有效减少函数执行次数,优化性能。

(3)副作用的挂载与清理
  • useEffect在组件挂载时执行,为window添加scroll事件监听,绑定节流后的函数;
  • useEffect的返回值是一个清理函数,在组件卸载时执行,移除scroll事件监听 —— 避免内存泄漏,是 React 函数式组件处理事件监听的标准写法;
  • useEffect的依赖数组包含threshold,确保当阈值变化时,重新绑定监听函数。

5. 条件渲染与 UI 展示

if(!isVisible) return null;

return (
    <Button variant="outline" size="icon" onClick={scrollTop} className="fixed bottom-6 right-6 rounded-full shadow-lg hover:shadow-xl z-50">
        <ArrowUp className="h-4 w-4" />
    </Button>
)
  • 条件渲染:当isVisiblefalse时,组件返回null,不渲染任何内容;仅当滚动距离超过阈值时,才渲染回到顶部按钮;

  • UI 设计细节:

    • 使用 UI 组件库的Button组件,设置variant="outline"(轮廓样式)、size="icon"(图标尺寸);
    • 通过className设置固定定位(fixed)、位置(bottom-6 right-6,右下角)、圆角(rounded-full)、阴影(shadow-lg/xl)、层级(z-50),确保按钮悬浮在页面最上层且样式美观;
    • 嵌入lucide-reactArrowUp图标作为按钮内容,直观传达 “回到顶部” 的功能;
    • 按钮绑定onClick事件,触发scrollTop函数。

三、节流工具函数(throttle)解析

组件依赖的throttle函数位于index.ts中,其实现如下:

type ThrottleFunction = (...args: any[]) => void;

export function throttle(fun: ThrottleFunction, delay: number): ThrottleFunction {
  let last: number | undefined;
  let deferTimer: NodeJS.Timeout | undefined;

  return function (...args: any[]) {
    const now = +new Date();

    if (last && now < last + delay) {
      clearTimeout(deferTimer);
      deferTimer = setTimeout(function () {
        last = now;
        fun(args);
      }, delay);
    } else {
      last = now;
      fun(args);
    }
  };
}

节流函数的核心原理

节流(Throttle)的核心思想是:在指定时间间隔内,只允许函数执行一次,即使触发多次,也仅生效一次。该实现的关键逻辑:

  1. 定义last(上一次函数执行的时间戳)和deferTimer(延迟定时器)两个闭包变量,用于记录执行状态;

  2. 每次触发函数时,获取当前时间戳now

  3. 若距离上一次执行时间不足delay

    • 清除原有定时器,避免重复执行;
    • 重新设置定时器,延迟delay后执行函数,并更新last
  4. 若距离上一次执行时间超过delay:直接执行函数,并更新last

注意点

该实现中fun(args)的传参方式需注意 —— 原函数的参数通过数组形式传递,若原函数依赖参数解构,需确保传参逻辑匹配(本文中toggleVisibility无参数,因此无影响)。

四、组件的使用与扩展

1. 基础使用

import BackToTop from '@/components/BackToTop';

const App = () => {
  return (
    <div>
      {/* 其他页面内容 */}
      <BackToTop threshold={500} />
    </div>
  );
};

仅需引入组件,可通过threshold自定义显示阈值,开箱即用。

2. 扩展方向

  • 自定义样式:通过className覆盖默认样式,或新增className Props 支持自定义样式;
  • 自定义图标:将图标作为 Props 传入,支持替换为自定义图标;
  • 滚动目标:扩展 Props 支持滚动到指定元素(而非仅顶部);
  • 动画效果:添加按钮显示 / 隐藏的过渡动画(如 React Transition Group);
  • 移动端适配:针对移动端调整按钮尺寸和位置;
  • 无障碍访问(a11y) :添加aria-label等属性,提升无障碍体验。

五、总结

本文解析的 BackToTop 组件是一个典型的 “小而美” 的 React 组件,其设计具备以下优点:

  1. 类型安全:通过 TypeScript 定义 Props 接口,确保类型校验;
  2. 性能优化:使用节流处理高频滚动事件,避免性能损耗;
  3. 用户体验:平滑滚动、条件渲染、美观的 UI 设计;
  4. 可维护性:模块化结构、清晰的逻辑拆分、完善的副作用清理;
  5. 可扩展性:通过 Props 暴露核心配置,便于扩展。

该组件的实现思路不仅适用于 “回到顶部” 功能,也可迁移到其他需要监听滚动事件的场景(如导航栏吸顶、懒加载等),是 React 函数式组件开发的典型实践案例。

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

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

Baked GI 节点是 Unity URP Shader Graph 中一个重要的光照计算节点,它允许着色器访问预计算的光照信息,为场景中的静态物体提供高质量的间接光照效果。在实时渲染中,全局光照(Global Illumination)的计算通常非常耗费性能,因此 Unity 提供了烘焙光照的解决方案,将复杂的光照计算预先处理并存储在光照贴图或光照探针中,运行时直接采样这些预计算数据,既能保证视觉效果又能保持高性能。

该节点的核心功能是根据物体的位置和朝向,从预先烘焙的光照数据中获取相应的光照颜色值。这些数据可以来自两种主要来源:光照贴图用于静态几何体,以及光照探针用于动态物体或需要动态光照的静态物体。通过合理使用 Baked GI 节点,开发者可以创建出具有丰富间接光照和真实感光照交互的着色器,而无需承担实时全局光照计算的性能开销。

在 URP 管线中,Baked GI 节点的实现经过了优化,专门针对移动平台和性能敏感的场景。与内置渲染管线或 HDRP 相比,URP 中的 Baked GI 节点可能有一些特定的限制和行为差异,但这些差异主要是为了确保在目标平台上的最佳性能表现。理解这些差异对于创建跨管线兼容的着色器至关重要。

描述

Baked GI 节点为着色器提供了访问烘焙全局光照值的能力,这些值可以在顶点着色器或片元着色器阶段使用。节点需要几个关键的输入参数来确定如何采样光照数据,包括世界空间中的位置和法线向量,以及用于光照贴图采样的 UV 坐标。

烘焙全局光照基础

烘焙全局光照是 Unity 光照系统的重要组成部分,它通过预计算场景中光线如何在不同表面之间反射和传播,生成静态的光照信息。这个过程包括直接光照和间接光照的计算,但只针对标记为静态的物体进行。烘焙完成后,光照信息会被存储到光照贴图或光照探针中:

  • 光照贴图是应用于静态几何体的纹理,包含预先计算的光照信息
  • 光照探针是在场景空间中放置的采样点,存储了该位置的光照信息,可用于动态物体或需要动态光照的静态物体

Baked GI 节点的作用就是在着色器执行时,根据提供的输入参数,从这些预计算的光照数据中获取相应的颜色值。

位置和法线输入的重要性

位置和法线输入对于正确采样光照探针数据至关重要。光照探针数据是基于球谐函数编码的,这种编码方式能够高效地存储全方向的光照信息。当着色器需要获取某点的光照信息时,系统会根据该点的位置找到最近的光照探针组,然后使用法线方向来评估球谐函数,得到该方向上的光照颜色。

如果提供的位置或法线不正确,可能会导致光照采样错误,表现为不自然的光照过渡或错误的光照方向。因此,确保这些输入参数的准确性是使用 Baked GI 节点的关键。

光照贴图坐标的作用

Static UV 和 Dynamic UV 输入用于采样不同类型的光照贴图:

  • Static UV 通常对应网格的 UV1 通道,用于采样静态光照贴图
  • Dynamic UV 通常对应网格的 UV2 通道,用于采样动态全局光照的光照贴图

在 Unity 的光照设置中,开发者可以选择使用不同的光照模式,如 Baked、Mixed 或 Realtime。对于 Mixed 光照模式的静态物体,Unity 会生成两套光照贴图:一套用于完全烘焙的光照,另一套用于与实时光照结合的效果。Baked GI 节点通过不同的 UV 输入来访问这些不同的光照贴图。

节点行为的管线依赖性

一个重要的注意事项是,Baked GI 节点的具体行为并未在全局范围内统一定义。Shader Graph 本身并不定义这个节点的功能实现,而是由每个渲染管线决定为此节点生成什么样的 HLSL 代码。这意味着:

  • 在高清渲染管线中,Baked GI 节点可能有特定的优化和功能
  • 在通用渲染管线中,节点的实现可能更注重性能和跨平台兼容性
  • 在内置渲染管线中,节点的行为可能又有所不同

这种设计使得每个渲染管线可以根据自身的架构和需求,优化 Baked GI 节点的实现方式。对于着色器开发者来说,这意味着如果计划创建在多种渲染管线中使用的着色器,需要在每个目标管线中测试 Baked GI 节点的行为,确保它按预期工作。

无光照着色器中的限制

在 URP 和 HDRP 中,Baked GI 节点不能在无光照着色器中使用。无光照着色器通常用于不需要复杂光照计算的物体,如UI元素、粒子效果或特殊效果。这些着色器通常会绕过管线的标准光照流程,因此无法访问烘焙全局光照数据。

如果尝试在无光照着色器中使用 Baked GI 节点,可能会遇到编译错误或运行时错误。对于需要简单光照的无光照物体,考虑使用其他光照技术,如顶点光照或简单的漫反射计算。

端口

Baked GI 节点包含多个输入端口和一个输出端口,每个端口都有特定的功能和数据要求。理解这些端口的作用对于正确使用节点至关重要。

Position 输入端口

Position 输入端口接收世界空间中的位置坐标,用于确定光照采样的空间位置。这个位置信息主要用于:

  • 光照探针采样:确定使用哪些光照探针的数据
  • 光照贴图索引:在某些情况下,帮助确定使用哪张光照贴图

在大多数情况下,应该将物体的世界空间位置连接到这个端口。在顶点着色器阶段使用 Baked GI 节点时,可以使用 Position 节点获取顶点在世界空间中的位置;在片元着色器阶段使用时,可以使用屏幕位置或通过其他方式计算得到的世界位置。

当使用光照探针时,位置输入的准确性尤为重要。如果位置偏差过大,可能会导致物体采样到错误位置的光照探针数据,造成光照不匹配的现象。

Normal 输入端口

Normal 输入端口接收世界空间中的法线向量,用于确定表面朝向,从而影响光照采样的方向。法线输入的主要作用包括:

  • 光照探针评估:球谐光照基于法线方向评估光照颜色
  • 光照贴图采样:在某些高级用法中,法线可能影响光照贴图的采样方式

法线向量应当是世界空间中的单位向量。如果提供的法线没有归一化,可能会导致光照计算错误。通常情况下,可以使用 Transform 节点将物体空间法线转换到世界空间,并确保使用正确的变换矩阵(通常是转置逆矩阵)。

对于动态法线效果(如法线贴图),需要将修改后的法线向量连接到 Normal 端口,这样 Baked GI 节点就会基于修改后的表面朝向计算光照,创造出更加丰富的视觉效果。

Static UV 输入端口

Static UV 输入端口用于指定静态光照贴图的纹理坐标。这些坐标通常对应于网格的 UV1 通道,也就是在建模软件中为光照贴图准备的 UV 集。Static UV 的作用包括:

  • 采样完全烘焙的光照贴图
  • 访问静态物体的间接光照信息
  • 在 Mixed 光照模式下,采样烘焙的间接光照部分

当场景中使用 Baked 或 Mixed 光照模式时,Unity 会为静态物体生成光照贴图。这些光照贴图包含了预计算的直接和间接光照信息。Static UV 输入确保着色器能够正确访问这些光照数据。

如果网格没有正确设置光照贴图 UV,或者 Static UV 输入不正确,可能会导致光照贴图采样错误,表现为拉伸、扭曲或重复的光照图案。

Dynamic UV 输入端口

Dynamic UV 输入端口用于指定动态光照贴图的纹理坐标,通常对应于网格的 UV2 通道。Dynamic UV 的主要应用场景包括:

  • 在 Mixed 光照模式下,采样用于实时光照交互的光照贴图
  • 访问动态全局光照系统生成的光照信息
  • 处理需要与实时光源交互的静态物体的光照

在 Mixed 光照模式下,Unity 会为静态物体生成两套光照贴图:一套用于完全烘焙的光照(通过 Static UV 访问),另一套用于与实时光源结合的效果(通过 Dynamic UV 访问)。这种设计允许静态物体既受益于高质量的烘焙光照,又能与场景中的实时光源正确交互。

Out 输出端口

Out 输出端口提供从烘焙全局光照系统采样的颜色值。这个输出是三维向量,表示 RGB 颜色空间中的光照颜色。输出的光照值已经考虑了:

  • 直接光照和间接光照的贡献
  • 颜色反射和光能传递效果
  • 场景的环境光遮蔽

输出的颜色值通常需要与材质的反照率颜色相乘,以实现正确的光照着色。在基于物理的着色模型中,Baked GI 的输出代表入射光强度,应当与表面反照率相乘来计算出射光强度。

在某些高级用法中,Baked GI 的输出可以用于更复杂的光照计算,如与实时光照结合,或作为其他着色效果的输入。

控件

Baked GI 节点提供了一个重要的控件选项,用于调整光照贴图的处理方式。

Apply Lightmap Scaling 切换

Apply Lightmap Scaling 是一个布尔切换控件,决定是否对光照贴图坐标自动应用缩放和偏移。这个选项默认为启用状态,在大多数情况下应该保持启用。

当启用 Apply Lightmap Scaling 时,节点会自动应用 Unity 光照系统中定义的光照贴图缩放和偏移变换。这些变换确保光照贴图正确映射到网格表面,考虑到了光照贴图的分包、排列和压缩设置。

禁用 Apply Lightmap Scaling 的情况较为少见,通常只在以下特定场景中考虑:

  • 当手动处理光照贴图坐标时
  • 当使用自定义的光照贴图布局时
  • 在某些特殊效果着色器中,需要直接访问原始光照贴图坐标

在大多数标准用法中,建议保持此选项启用,以确保光照贴图正确映射。如果禁用此选项,需要手动确保光照贴图坐标的正确性,否则可能导致光照贴图采样错误。

生成代码示例

Baked GI 节点在生成着色器代码时,会根据所在的渲染管线产生相应的 HLSL 代码。以下示例展示了 URP 中 Baked GI 节点可能生成的代码结构。

基本函数定义

HLSL

void Unity_BakedGI_float(float3 Position, float3 Normal, float2 StaticUV, float2 DynamicUV, out float3 Out)
{
    Out = SHADERGRAPH_BAKED_GI(Position, Normal, StaticUV, DynamicUV, false);
}

这个函数定义展示了 Baked GI 节点的基本代码结构。函数接收位置、法线和光照贴图坐标作为输入,通过 SHADERGRAPH_BAKED_GI 宏计算烘焙全局光照值,并将结果输出到 Out 参数。

SHADERGRAPH_BAKED_GI 是一个由 Shader Graph 系统定义的宏,它的具体实现取决于目标渲染管线。在 URP 中,这个宏会展开为访问 URP 烘焙光照系统的代码。

实际应用示例

在实际的着色器中,Baked GI 节点通常与其他光照计算结合使用。以下是一个简单的表面着色器示例,展示如何将 Baked GI 与实时直接光照结合:

HLSL

void surf(Input IN, inout SurfaceOutputStandard o)
{
    // 采样反照率贴图
    fixed4 albedo = tex2D(_MainTex, IN.uv_MainTex) * _Color;

    // 获取烘焙全局光照
    float3 bakedGI;
    Unity_BakedGI_float(IN.worldPos, IN.worldNormal, IN.uv1, IN.uv2, bakedGI);

    // 计算实时直接光照(简化示例)
    float3 directLight = _LightColor0 * max(0, dot(IN.worldNormal, _WorldSpaceLightPos0.xyz));

    // 结合光照
    o.Albedo = albedo.rgb;
    o.Emission = bakedGI * albedo.rgb;
    // 直接光照已经在光照模型中处理
}

这个示例展示了烘焙间接光照与实时直接光照的基本结合方式。在实际的 URP 着色器中,光照计算可能更加复杂,涉及更多光照模型和渲染特性。

顶点与片元着色器中的使用

Baked GI 节点既可以在顶点着色器中使用,也可以在片元着色器中使用,取决于性能和质量的需求:

顶点着色器中使用:

HLSL

v2f vert (appdata v)
{
    v2f o;
    // ... 其他顶点变换

    // 在顶点着色器中计算烘焙GI
    Unity_BakedGI_float(mul(unity_ObjectToWorld, v.vertex).xyz,
                        normalize(mul(v.normal, (float3x3)unity_WorldToObject)),
                        v.uv1, v.uv2, o.bakedGI);

    return o;
}

片元着色器中使用:

HLSL

fixed4 frag (v2f i) : SV_Target
{
    // 在片元着色器中计算烘焙GI(更高质量)
    float3 bakedGI;
    Unity_BakedGI_float(i.worldPos, normalize(i.worldNormal), i.uv1, i.uv2, bakedGI);

    // ... 其他着色计算
}

在顶点着色器中使用 Baked GI 性能更好,但光照细节较少;在片元着色器中使用质量更高,但性能开销更大。根据目标平台和性能要求选择合适的阶段。

最佳实践和性能考虑

使用 Baked GI 节点时,遵循一些最佳实践可以确保最佳的性能和视觉效果。

光照贴图设置优化

确保场景的光照贴图设置正确优化:

  • 使用适当的光照贴图分辨率,平衡质量和内存使用
  • 合理设置光照贴图压缩,在移动平台上使用压缩格式
  • 对不需要高质量光照的物体使用较低的光照贴图分辨率

光照探针布局优化

光照探针的布局影响动态物体的光照质量:

  • 在光照变化明显的区域放置更多光照探针
  • 确保动态物体的移动路径上有足够的光照探针覆盖
  • 使用光照探针代理卷提高大范围区域的光照探针效率

着色器性能优化

在着色器中使用 Baked GI 节点时考虑性能:

  • 在移动平台上,考虑在顶点着色器中使用 Baked GI
  • 对于远处物体,使用简化的光照计算
  • 避免在透明物体的着色器中过度使用复杂的光照计算

跨管线兼容性

如果计划创建跨渲染管线使用的着色器:

  • 在目标管线中测试 Baked GI 节点的行为
  • 使用着色器变体或自定义函数处理管线特定的差异
  • 提供回退方案,当 Baked GI 节点不可用时使用替代光照计算

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

TypeScript 核心基础知识

TypeScript(简称 TS)作为 JavaScript 的超集,已成为前端工程化的标配。它通过静态类型检查,提前规避大量运行时错误,让代码更易维护、更具可读性。本文抛开复杂概念,从新手视角梳理 TS 核心基础知识,看完就能上手写 TS 代码。

一、为什么要学 TypeScript?

先明确学习的意义,避免盲目跟风:

  1. 静态类型检查:编码阶段发现错误(如类型不匹配、属性不存在),而非运行时崩溃;
  2. 更好的代码提示:VS Code 等编辑器能精准提示变量 / 函数的属性和方法,提升开发效率;
  3. 代码可读性提升:类型注解就是 “自文档”,一眼看懂变量 / 函数的用途;
  4. 工程化必备:Vue3、React、Node.js 主流框架 / 环境均推荐 / 支持 TS,大厂项目标配。

二、TS 环境搭建(快速上手)

1. 安装 TypeScript

# 全局安装 TS 编译器
npm install -g typescript
# 验证安装(查看版本)
tsc -v

2. 第一个 TS 程序

  • 创建 hello.ts 文件:

    // 类型注解:指定变量类型为字符串
    const message: string = "Hello TypeScript!";
    console.log(message);
    
  • 编译 TS 为 JS:

    # 将 hello.ts 编译为 hello.js
    tsc hello.ts
    
  • 运行 JS 文件:

    node hello.js
    

3. 简化开发:自动编译 + 热更新(可选)

# 安装 ts-node(直接运行 TS,无需手动编译)
npm install -g ts-node
# 直接运行 TS 文件
ts-node hello.ts

三、核心基础:类型注解与类型推断

1. 类型注解(手动指定类型)

语法:变量名: 类型 = 值,告诉 TS 变量的具体类型。

// 基本类型注解
let name: string = "张三"; // 字符串
let age: number = 25; // 数字(整数/浮点数/NaN/Infinity)
let isAdult: boolean = true; // 布尔值
let empty: null = null; // null
let undef: undefined = undefined; // undefined

// 数组注解(两种写法)
let arr1: string[] = ["苹果", "香蕉"]; // 推荐
let arr2: Array<number> = [1, 2, 3]; // 泛型写法

// 对象注解
let user: { name: string; age: number } = {
  name: "李四",
  age: 30,
};

// 函数注解(参数 + 返回值)
function add(a: number, b: number): number {
  return a + b;
}

2. 类型推断(TS 自动推导类型)

TS 会根据变量的初始值自动推断类型,无需手动注解(日常开发中优先用推断,减少冗余)。

typescript

运行

let str = "hello"; // TS 自动推断 str 为 string 类型
str = 123; // 报错:不能将类型“number”分配给类型“string”

let num = 100; // 推断为 number 类型
let bool = false; // 推断为 boolean 类型

核心原则:能靠推断的就不手动注解,需要明确约束时才加注解。

四、常用基础类型

1. 原始类型

表格

类型 说明 示例
string 字符串 let str: string = "TS"
number 数字 let num: number = 666
boolean 布尔值 let flag: boolean = false
null 空值 let n: null = null
undefined 未定义 let u: undefined = undefined
symbol 唯一值 let s: symbol = Symbol("id")
bigint 大整数 let b: bigint = 100n

2. 数组

两种写法,推荐第一种:

// 写法1:类型[]
let numbers: number[] = [1, 2, 3];
// 写法2:Array<类型>
let strings: Array<string> = ["a", "b"];
// 禁止混合类型(除非指定联合类型)
let mix: (string | number)[] = [1, "a"]; // 联合类型:字符串或数字

3. 元组(Tuple)

固定长度、固定类型的数组(强约束):

// 元组注解:第一个元素是string,第二个是number
let tuple: [string, number] = ["张三", 25];
tuple[0] = "李四"; // 合法
tuple[1] = 30; // 合法
tuple.push(3); // 注意:push 不会报错(TS 设计缺陷),但访问 tuple[2] 会报错

4. 任意类型(any)

关闭 TS 类型检查,慎用(失去 TS 核心价值):

let anyValue: any = "hello";
anyValue = 123; // 不报错
anyValue = true; // 不报错
anyValue.foo(); // 不报错(运行时可能崩溃)

5. 未知类型(unknown)

安全版 any,必须先类型校验才能使用:

let unknownValue: unknown = "hello";
// unknownValue.toUpperCase(); // 报错:不能直接调用方法

// 先校验类型,再使用
if (typeof unknownValue === "string") {
  unknownValue.toUpperCase(); // 合法
}

6. 空类型(void)

表示函数没有返回值(或返回 undefined):

function logMsg(): void {
  console.log("这是一个无返回值的函数");
  // 省略 return 或 return undefined 均合法
}

7. 永不类型(never)

表示永远不会发生的值(如抛出错误、无限循环):

// 抛出错误的函数,返回值为 never
function throwError(): never {
  throw new Error("出错了!");
}

// 无限循环的函数,返回值为 never
function infiniteLoop(): never {
  while (true) {}
}

五、进阶基础:接口与类型别名

1. 接口(interface)

用于约束对象的结构,可扩展、可实现,是 TS 中定义对象类型的核心方式:

// 定义接口
interface User {
  name: string; // 必选属性
  age: number; // 必选属性
  gender?: string; // 可选属性(加 ?)
  readonly id: number; // 只读属性(不可修改)
}

// 使用接口约束对象
let user: User = {
  name: "张三",
  age: 25,
  id: 1001,
  // gender 可选,可省略
};

user.id = 1002; // 报错:只读属性不能修改

2. 类型别名(type)

给类型起别名,适用范围更广(可约束任意类型,不止对象):

// 基本类型别名
type Str = string;
let str: Str = "hello";

// 对象类型别名
type User = {
  name: string;
  age: number;
};

// 联合类型别名
type NumberOrString = number | string;
let value: NumberOrString = 100;
value = "abc";

3. interface vs type 核心区别

表格

特性 interface type
扩展 可通过 extends 扩展 可通过 & 交叉扩展
重复定义 支持(自动合并) 不支持(会报错)
适用范围 主要约束对象 / 类 可约束任意类型(基本类型、联合类型等)

使用建议:定义对象 / 类的结构用 interface,其他场景用 type

六、函数相关类型

1. 函数参数与返回值注解

// 普通函数
function sum(a: number, b: number): number {
  return a + b;
}

// 箭头函数
const multiply = (a: number, b: number): number => {
  return a * b;
};

// 无返回值
const log = (msg: string): void => {
  console.log(msg);
};

2. 可选参数与默认参数

// 可选参数(加 ?,必须放在必选参数后面)
function greet(name: string, age?: number): void {
  console.log(`姓名:${name},年龄:${age || "未知"}`);
}
greet("张三"); // 合法
greet("李四", 30); // 合法

// 默认参数(自动推断类型,无需加 ?)
function sayHi(name: string = "游客"): void {
  console.log(`你好,${name}`);
}
sayHi(); // 输出:你好,游客

3. 函数类型别名

定义函数的 “形状”(参数类型 + 返回值类型):

// 定义函数类型
type AddFn = (a: number, b: number) => number;

// 实现函数
const add: AddFn = (x, y) => {
  return x + y;
};

七、类型守卫

通过代码逻辑缩小类型范围,让 TS 更精准推断类型:

// typeof 类型守卫(适用于原始类型)
function printValue(value: string | number) {
  if (typeof value === "string") {
    console.log(value.toUpperCase()); // TS 知道这里 value 是 string
  } else {
    console.log(value.toFixed(2)); // TS 知道这里 value 是 number
  }
}

// instanceof 类型守卫(适用于类实例)
class Animal {}
class Dog extends Animal {
  bark() {
    console.log("汪汪汪");
  }
}

function judgeAnimal(animal: Animal) {
  if (animal instanceof Dog) {
    animal.bark(); // TS 知道这里 animal 是 Dog 实例
  }
}

八、TS 配置文件(tsconfig.json)

项目中通过 tsconfig.json 配置 TS 编译规则,执行 tsc --init 生成默认配置,核心配置说明:

{
  "compilerOptions": {
    "target": "ES6", // 编译目标 JS 版本(ES5/ES6/ESNext)
    "module": "ESNext", // 模块系统(CommonJS/ESModule)
    "outDir": "./dist", // 编译后的 JS 文件输出目录
    "rootDir": "./src", // 源文件目录
    "strict": true, // 开启严格模式(推荐,强制类型检查)
    "noImplicitAny": true, // 禁止隐式 any 类型
    "esModuleInterop": true // 兼容 CommonJS 和 ESModule
  },
  "include": ["./src/**/*"], // 要编译的文件
  "exclude": ["node_modules"] // 排除的文件
}

九、新手避坑指南

  1. 不要滥用 any:用 unknown 替代 any,保留类型检查;
  2. 可选参数放最后:TS 要求可选参数必须在必选参数之后;
  3. 元组 push 不报错:元组虽固定长度,但 push 不会触发 TS 报错,需手动规避;
  4. 严格模式必开strict: true 能暴露更多潜在问题,是 TS 核心价值所在;
  5. 类型断言要谨慎as 语法是 “告诉 TS 我比你更清楚类型”,滥用会导致类型不安全。

总结

  1. TS 核心是静态类型系统,通过类型注解 / 推断提前规避错误;
  2. 常用基础类型:原始类型、数组、元组、any/unknown、void/never,需掌握各自使用场景;
  3. 定义对象结构优先用 interface,其他类型约束用 type
  4. 函数注解要关注参数、返回值、可选参数,类型守卫能提升类型推断精度;
  5. 项目中务必开启严格模式(strict: true),发挥 TS 最大价值。

从 JS 过渡到 TS 无需一步到位,可先在项目中局部使用,逐步覆盖,重点是理解 “类型” 的核心思想,而非死记语法。掌握本文的基础知识,足以应对日常开发中 80% 的 TS 场景,后续可再深入泛型、装饰器、高级类型等内容。

JavaScript 手写 new 操作符:深入理解对象创建

当我们使用 new 关键字时,背后到底发生了什么?这个看似简单的操作,实际上完成了一系列复杂的步骤。理解 new 的工作原理,是掌握 JavaScript 面向对象编程的关键。

前言:从 new 的神秘面纱说起

function Person(name, age) {
    this.name = name;
    this.age = age;
}

Person.prototype.greet = function() {
    return `你好,我是${this.name},今年${this.age}岁`;
};

const person = new Person('张三', 25);

上述代码中 new 到底创建了什么?为什么 this 指向了新对象?原型链是怎么建立的?如果构造函数有返回值会怎样?我们将通过本篇文章揭开 new 的神秘面纱,从零实现一个自己的 new 操作符。

理解 new

new 的四个核心步骤:

  1. 创建一个空对象
  2. 将对象的原型设置为构造函数的 prototype 属性
  3. 将构造函数的 this 绑定到新对象,并执行构造函数
  4. 判断返回值类型:如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

手写实现 new 操作符

基础版本实现

function myNew(constructor, ...args) {

  // 1. 创建一个空对象
  const obj = {};

  // 2. 将对象的原型设置为构造函数的 prototype 属性
  Object.setPrototypeOf(obj, constructor.prototype);

  // 3. 将构造函数的 this 绑定到新对象,并执行构造函数
  const result = constructor.apply(obj, args);

  // 4. 判断返回值类型
  // 如果构造函数返回一个对象(包括函数),则返回该对象
  // 否则返回新创建的对象
  const isObject = result !== null && (typeof result === 'object' || typeof result === 'function');

  return isObject ? result : obj;
}

处理边界情况

function myNewEnhanced(constructor, ...args) {

  // 边界情况1:constructor 不是函数
  if (typeof constructor !== 'function') {
    throw new TypeError(`${constructor} is not a constructor`);
  }

  // 边界情况2:箭头函数(没有 prototype)
  if (!constructor.prototype) {
    throw new TypeError(`${constructor.name || constructor} is not a constructor`);
  }

  // 1. 创建新对象(改进方法):使用 Object.create 更优雅地设置原型
  const obj = Object.create(constructor.prototype);

  // 2. 调用构造函数
  let result;
  try {
    result = constructor.apply(obj, args);
  } catch (error) {
    // 如果构造函数抛出异常,直接传播
    throw error;
  }

  // 3. 处理返回值
  // 注意:null 也是 object 类型,但需要特殊处理
  const resultType = typeof result;
  const isObject = result !== null && (resultType === 'object' || resultType === 'function');

  return isObject ? result : obj;
}

完整实现与原型链优化

function myNewComplete(constructor, ...args) {
  // 1. 参数验证
  if (typeof constructor !== 'function') {
    throw new TypeError(`Constructor ${constructor} is not a function`);
  }

  // 2. 检查是否为可构造的函数:箭头函数和部分内置方法没有 prototype
  if (!constructor.prototype && !isNativeConstructor(constructor)) {
    throw new TypeError(`${getFunctionName(constructor)} is not a constructor`);
  }

  // 3. 创建新对象并设置原型链
  const proto = constructor.prototype || Object.prototype;
  const obj = Object.create(proto);

  // 4. 绑定 constructor 属性
  obj.constructor = constructor; 

  // 5. 执行构造函数
  const result = Reflect.construct(constructor, args, constructor);

  // 6. 处理返回值
  // Reflect.construct 已经处理了返回值逻辑
  // 但我们还是实现自己的逻辑以保持一致
  return processConstructorResult(result, obj, constructor);
}

// 辅助函数:检查是否为原生构造函数
function isNativeConstructor(fn) {
  // 一些内置构造函数如 Symbol、BigInt 没有 prototype
  const nativeConstructors = [
    'Number', 'String', 'Boolean', 'Symbol', 'BigInt',
    'Date', 'RegExp', 'Error', 'Array', 'Object', 'Function'
  ];

  return nativeConstructors.some(name =>
    fn.name === name || fn === globalThis[name]
  );
}

// 辅助函数:获取函数名
function getFunctionName(fn) {
  if (fn.name) return fn.name;
  const match = fn.toString().match(/^function\s*([^\s(]+)/);
  return match ? match[1] : 'anonymous';
}

// 辅助函数:处理构造函数返回值
function processConstructorResult(result, defaultObj, constructor) {
  // 如果 result 是 undefined 或 null,返回 defaultObj
  if (result == null) {
    return defaultObj;
  }

  // 检查 result 的类型
  const type = typeof result;

  // 如果是对象或函数,返回 result
  if (type === 'object' || type === 'function') {
    // 额外检查:如果 result 是构造函数本身的实例,确保原型链正确
    if (result instanceof constructor) {
      return result;
    }
    return result;
  }

  // 原始值,返回 defaultObj
  return defaultObj;
}

深入原型链与继承

原型链的建立过程

// 父构造函数
function Animal(name) {
  this.name = name;
}
// 父类方法
Animal.prototype.speak = function () {
  return `${this.name} 叫了`;
};
// 子构造函数
function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}
// 建立原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;
// 子类方法
Dog.prototype.bark = function () {
  return `${this.name} 汪汪叫`;
};
// 创建实例
const myDog = new Dog('旺财', '金毛');
console.log(myDog.speak()); // 旺财 叫了
console.log(myDog.bark());  // 旺财 汪汪叫

ES6 类与 new 的关系

ES6 类的本质还是基于原型的语法糖:

ES6 基本写法

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }

  greet() {
    return `你好,我是${this.name}`;
  }
}
const person = new Person('张三', 30);

对应 ES5 的写法

function PersonES5(name, age) {
  // 类构造器中的代码
  if (!(this instanceof PersonES5)) {
    throw new TypeError("Class constructor Person cannot be invoked without 'new'");
  }

  this.name = name;
  this.age = age;
}

// 实例方法(添加到原型)
PersonES5.prototype.greet = function () {
  return `你好,我是${this.name}`;
};
const personES5 = new PersonES5('李四', 25);

类的重要特性

  1. 类必须用 new 调用
  2. 类方法不可枚举
  3. 类没有变量提升

ES6 实现继承的完整示例

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' 叫了');
  }
}
class Dog extends Animal {
  constructor(name) {
    super(name);
  }

  speak() {
    console.log(this.name + ' 汪汪叫');
  }
}

ES6 继承的本质

ES6 通过 extends 关键字实现继承,就等价于 ES5 的寄生组合继承:

function AnimalES5(name) {
  this.name = name;
}

AnimalES5.prototype.speak = function () {
  console.log(this.name + ' 叫了');
};

function DogES5(name) {
  AnimalES5.call(this, name);
}

// 设置原型链
DogES5.prototype = Object.create(AnimalES5.prototype);
DogES5.prototype.constructor = DogES5;

DogES5.prototype.speak = function () {
  console.log(this.name + ' 汪汪叫');
};

特殊场景与高级应用

单例模式与 new

方法1:使用静态属性

class SingletonV1 {
  static instance = null;

  constructor(name) {
    if (SingletonV1.instance) {
      return SingletonV1.instance;
    }

    this.name = name;
    SingletonV1.instance = this;
  }

  static getInstance(name) {
    if (!this.instance) {
      this.instance = new SingletonV1(name);
    }
    return this.instance;
  }
}

方法2:使用闭包

const SingletonV2 = (function () {
  let instance = null;

  return class Singleton {
    constructor(name) {
      if (instance) {
        return instance;
      }

      this.name = name;
      instance = this;
    }
  };
})();

方法3:代理模式

function createSingletonProxy(Class) {
  let instance = null;

  return new Proxy(Class, {
    construct(target, args) {
      if (!instance) {
        instance = Reflect.construct(target, args);
      }
      return instance;
    }
  });
}

实现 Object.create 的 polyfill

if (typeof Object.create !== 'function') {
  Object.create = function (proto, propertiesObject) {
    // 参数验证
    if (typeof proto !== 'object' && typeof proto !== 'function') {
      throw new TypeError('Object prototype may only be an Object or null');
    }

    // 核心实现:使用空函数作为中间构造函数
    function F() { }
    F.prototype = proto;

    // 创建新对象,原型指向proto
    const obj = new F();

    // 处理第二个参数(属性描述符)
    if (propertiesObject !== undefined) {
      Object.defineProperties(obj, propertiesObject);
    }

    // 处理 null 原型
    if (proto === null) {
      obj.__proto__ = null;
    }
    // 返回新对象
    return obj;
  };
}

常见面试问题与解答

问题1:new 操作符做了什么?

  1. 创建一个新的空对象',
  2. 将这个空对象的原型设置为构造函数的 prototype 属性',
  3. 将构造函数的 this 绑定到这个新对象,并执行构造函数',
  4. 如果构造函数返回一个对象(包括函数),则返回该对象;否则返回新创建的对象

问题2:如果构造函数有返回值会怎样?

  • 返回对象(包括函数):忽略 this 绑定的对象,返回该对象
  • 返回原始值(number, string, boolean等):忽略返回值,返回 this 绑定的对象
  • 没有 return 语句:隐式返回 undefined,返回 this 绑定的对象

问题3:如何判断函数是否被 new 调用?

  • ES5:检查 this instanceof Constructor'
  • ES6+:使用 new.target(更准确)
  • 箭头函数:不能作为构造函数,没有 new.target

结语

通过深入理解 new 操作符的工作原理,我们不仅能在面试中脱颖而出,还能在实际开发中做出更明智的设计决策。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

企业级 Prompt 工程实战指南(下):构建可复用 Prompt 架构平台

一、前言:从“懂原理”到“能落地”

在上篇内容中企业级 Prompt 工程实战指南(上):别让模糊指令浪费你的AI算力,我们拆解了 Prompt 的底层逻辑、四大核心要素,以及四大典型避坑技巧,解决了“怎么写才不踩坑”的基础问题。

但对一线开发者和架构师而言,Prompt 工程的最终价值,不在于“懂原理”,而在于“能落地”——如何将 Prompt 设计融入实际业务,降低开发成本、提升效率,构建可复用、可迭代的 Prompt 体系?

本篇将聚焦实战,通过完整业务案例拆解落地流程,对比不同技术路径的优劣,分享工程化落地技巧,并展望未来发展趋势,真正把 Prompt 技术转化为业务竞争力。

二、实战案例:企业客服工单自动分类与摘要生成

为了更直观地展示 Prompt 工程在实际业务中的应用效果,我们以一家电商企业的售后客服场景为例,详细拆解如何通过精心设计的 Prompt 实现工单的自动分类与摘要生成,大幅提升客服工作效率。

2.1 场景角色

  • AI 应用产品经理(Prompt 设计者) :负责设计和优化 Prompt,确保大语言模型能够准确理解业务需求并生成高质量的输出。
  • 客服团队(需求方) :每天需要处理大量的售后工单,希望借助 AI 技术实现工单的自动分类和摘要生成,以减轻工作负担,提高服务效率。
  • 大模型(执行主体) :选用市面上成熟的大语言模型,如 ChatGPT、Gemini、通义千问等,作为执行任务的核心引擎,根据输入的 Prompt 和工单文本进行分析和处理。
  • 服务对象:日均产生 500 + 售后工单的电商售后部门,涵盖各类复杂的客户问题和诉求。

2.2 核心目标

通过优化 Prompt 设计,让大语言模型自动将杂乱无章的售后工单准确分类为 “物流问题”“产品故障”“退换货申请” 三类,并为每个工单生成 50 字以内的结构化处理摘要,清晰概括核心诉求与关键信息。目标是替代人工分类,将整体工作效率提升 30% 以上,同时保证分类准确率达到 95% 以上,摘要关键信息覆盖率达到 90% 以上。

2.3 输入

  • 原始输入:无结构化的售后工单文本,例如 “我买的衣服尺码不对,昨天收到的,想换大一码,请问需要寄回吗?” 这类文本通常表述随意,包含大量冗余信息,需要模型进行信息提取和分类。

  • 辅助输入(少样本学习) :为了引导模型更好地理解任务,提供 3 条分类示例,如:

    • 示例 1:“我买的手机三天了还没收到,单号查不到,啥情况?” - 分类:物流问题;摘要:用户反映手机未收到且单号查询无果。
    • 示例 2:“刚用的吹风机,突然冒烟了,不敢再用了。” - 分类:产品故障;摘要:用户反馈吹风机使用中冒烟。
    • 示例 3:“买的电脑配置和宣传不符,申请退货。” - 分类:退换货申请;摘要:用户因电脑配置不符申请退货。

2.4 处理流程(工具调用逻辑)

  • 第一步:编写系统 Prompt:“你是电商售后工单分类专家,需完成 2 个任务:1. 将工单分为物流问题 / 产品故障 / 退换货申请三类;2. 生成 50 字内处理摘要,包含核心诉求与关键信息。” 此系统 Prompt 明确了模型的角色和任务范围,为后续处理奠定基础。
  • 第二步:加入少样本示例:将上述 3 条分类示例加入 Prompt 中,让模型通过少样本学习掌握分类和摘要生成的模式与规则,增强模型对任务的理解和适应性。
  • 第三步:输入用户工单文本:将实际的售后工单文本输入给模型,与系统 Prompt 和少样本示例共同构成完整的输入信息,触发模型的处理流程。
  • 第四步:输出结构化结果:模型根据输入信息进行分析处理,输出结构化的结果,格式为 “分类:[具体类别];摘要:[处理摘要]”。整个过程无需对模型进行微调,仅通过精心设计的 Prompt 即可实现高效的任务处理。

2.5 输出与校验

  • 输出格式:“分类:退换货申请;摘要:用户购买衣服尺码不符,昨日收货,需求换货大一码,咨询寄回流程”。这种结构化的输出便于客服人员快速理解工单内容,提高处理效率。

  • 校验标准

    • 分类准确率:通过人工抽样复核 100 条工单,对比模型分类结果与人工标注结果,要求分类准确率达到 95% 以上。
    • 摘要关键信息覆盖率:同样抽样 100 条工单,检查摘要是否涵盖用户核心诉求和关键信息,如问题类型、涉及产品、关键时间等,覆盖率需达到 90% 以上。

三、技术路径对比:不同 Prompt 策略的适用场景与成本分析

3.1 三类主流 Prompt 技术路径对比表

在实际应用中,零样本、少样本和思维链(CoT)这三类 Prompt 技术路径各有优劣,适用于不同的业务场景。下面通过表格对比,我们可以更清晰地了解它们在设计思路、优势、劣势、适用场景以及技术成本等方面的差异。

技术路径 设计思路 优势 劣势 适用场景 技术成本 实现复杂度 落地可行性
零样本 Prompt 仅输入任务描述,无示例 成本最低、无需准备样本、迭代快 准确率低、复杂任务易失控 简单文本生成、基础问答 极低(仅需指令设计) 极高(即写即用)
少样本 Prompt 加入 3-5 个示例引导模型 准确率高于零样本、适配多数场景 需准备标注示例、指令长度受限 文本分类、摘要生成、格式标准化 低(样本标注成本低) 高(中小规模业务首选)
思维链(CoT)Prompt 引导模型分步推理,展示思考过程 适配复杂逻辑任务、推理准确率高 指令设计复杂、token 消耗大、速度慢 数学计算、故障排查、多步骤决策 中(需设计推理框架) 中(适合专业场景)

3.2 技术选型核心原则:成本与效果的平衡

从高层往下看视角看,技术选型需遵循 “低成本优先” 原则:优先用零样本 Prompt 解决简单任务;中等复杂度任务采用少样本 Prompt,以最低标注成本提升准确率;仅复杂推理任务考虑思维链 Prompt,同时需评估 token 消耗带来的算力成本,避免过度设计。在实际应用中,我们要根据任务的复杂度、数据资源、算力成本等多方面因素,综合评估选择最合适的 Prompt 技术路径,以实现最佳的性价比。例如,在一个简单的文本分类任务中,如果使用思维链 Prompt,虽然可能会提高准确率,但由于其指令设计复杂、token 消耗大,会增加不必要的成本,此时选择少样本 Prompt 可能更为合适。

四、Prompt 工程化落地:从 “一次性指令” 到 “可复用架构”

当我们在实际业务中大规模应用 Prompt 技术时,就不能仅仅满足于 “一次性” 的指令设计,而需要从工程化的角度构建一套可复用、可迭代、低成本的 Prompt 架构体系。这不仅关系到开发效率与成本控制,更是决定 AI 应用能否在复杂业务环境中持续稳定运行的关键。

4.1 模块化设计:Prompt 模板化与组件化

从工程实践看,将 Prompt 拆分为多个可复用组件是提高开发效率与灵活性的关键。一个典型的 Prompt 可以拆解为 “角色定义 + 任务指令 + 格式约束 + 示例” 四大组件。以电商客服场景为例,我们可以将 “你是专业电商客服” 这一角色定义固化为通用组件;任务指令部分则根据不同工单类型(如物流咨询、产品售后等)动态替换;格式约束(如 “输出为 JSON 格式”)和示例(如常见问题及解答示例)也可按需调整。通过这种组件化设计,我们可以快速搭建针对不同业务场景的 Prompt,实现跨工单类型的快速适配,大幅降低重复开发成本。这种方式就像是搭积木,每个组件都是一个独立的模块 ,我们可以根据不同的业务需求,灵活地组合这些模块,快速构建出满足需求的 Prompt。在这之后还会专门搭建 Prompt 平台,专门存储和编写 Prompt,一键更新到 AI 应用里面,方便 Prompt 各种环境使用和进行版本管理

4.2 迭代优化:基于输出反馈的指令调优

Prompt 并非一成不变,而是需要根据模型输出结果持续优化。建立 “指令 - 输出 - 反馈 - 优化” 的闭环迭代流程是实现这一目标的核心。例如,在工单分类任务中,如果模型将某个 “产品故障” 工单误分类为 “物流问题”,我们需要深入分析指令设计的漏洞,比如是否存在未覆盖的边缘场景、示例是否足够典型等。

针对这些问题,我们可以补充更多边缘场景的示例,细化分类规则,逐步提高模型的准确率。这种迭代优化的过程就像是对产品进行持续改进,通过不断收集用户反馈,优化产品功能,提升用户体验。

在这里,我想额外问一个问题 在进行 prompt 更新的时候,如何去评判 Prompt 前后两次修改的质量好坏呢? 我列出三个纬度供大家参考

  • 质量维度,能说到重点上吗?
  • 稳定性纬度,每次问都回答一样吗?
  • 正确性纬度,回答的数据正确吗?

4.3 成本控制:减少无效 token 消耗

在实际应用中,token 消耗不单单会影响大模型幻觉,还会直接关系到算力成本,因此从工程化角度优化 token 使用至关重要。首先,要精简指令内容,避免冗长复杂的表述,确保每一个 token 都传递有效信息;

其次,合理利用模型上下文窗口特性,优先保留系统 Prompt 中的核心规则与约束,对用户输入中的冗余信息进行预处理;对于超长文本任务,结合检索增强生成(RAG)技术,将长文本拆分为多个短文本分批次输入,避免一次性输入导致的 token 溢出。这就好比在装修房子时,合理规划空间,避免浪费,让每一寸空间都得到充分利用。通过这些策略,可以在保证模型性能的前提下,有效降低 token 成本,提高应用的性价比。

五、总结与展望:Prompt 工程的现在与趋势

5.1 核心观点总结

Prompt 工程的本质是 “用工程化思维替代感性经验”,核心在于明确角色、拆解任务、约束格式、补充示例,而非依赖模型参数提升。对于多数企业级应用,优质 Prompt 设计带来的效果提升,远高于盲目追求大模型升级的收益。在实际应用中,我们不应过分关注模型的参数规模和性能指标,而应将更多的精力放在如何设计有效的 Prompt 上。通过合理的 Prompt 设计,我们可以引导模型更好地理解任务需求,提高输出的质量和准确性,从而实现更高的性价比。

5.2 当前局限性

现有 Prompt 技术仍存在边界:无法突破模型预训练知识范围,易产生 “幻觉” ;复杂任务的指令设计依赖专业经验;多模态场景下的 Prompt 设计尚未形成标准化方案。例如,当我们询问模型关于未来的科技发展趋势时,由于模型的知识截止于训练时间,它无法提供最新的信息,可能会产生不准确或过时的回答。在多模态场景下,如结合图像和文本的应用中,如何设计有效的 Prompt 以实现多模态信息的融合和交互,仍然是一个待解决的问题。

5.3 目前趋势展望

目前 Prompt 工程将向 “自动化” 与 “融合化” 发展:自动化方面,AI 将自主生成并优化 Prompt,降低人工设计门槛;融合化方面,Prompt 将与 RAG 深度结合,形成 “Prompt+RAG 解决知识时效性的 SOP。随着技术的不断发展,我们可以期待 AI 能够根据用户的需求自动生成和优化 Prompt,进一步提高效率和准确性。Prompt 与其他技术的融合也将为 AI 应用带来更多的可能性,推动 AI 技术在各个领域的深入应用和发展。

感谢观看,欢迎大家点赞关注,下期更精彩!

JavaScript 手写 call、apply、bind:深入理解函数上下文绑定

当面试官让我们手写 call、apply、bind 时,他们真正考察的是什么?这三个方法看似简单,却隐藏着 JavaScript 函数执行上下文、原型链、参数处理等核心概念。本文将从零实现,并深入理解它们的差异和应用场景。

前言:为什么需要 call、apply、bind?

const obj = {
  name: '张三',
  sayHello() {
    console.log(`你好,我是${this.name}`);
  }
};

const sayHelloFunc = obj.sayHello;
obj.sayHello();     // "你好,我是张三" - 正确
sayHelloFunc();     // "你好,我是undefined" - this丢失了!

上述代码,出现问题根源是:函数的 this 在调用时才确定,取决于调用方式。那如何解决呢?使用call、apply、bind 显式绑定 this 。

call 方法的实现

call 的基本使用

call 方法用于调用一个函数,并显式指定函数的 this 值和参数列表。

function greet(message) {
  console.log(`${message}, ${this.name}!`);
}

const person = { name: 'zhangsan' };

// 原生 call 的使用
greet.call(person, '你好'); // "你好, zhangsan!"

call 的工作原理

  1. 将函数设为对象的属性
  2. 使用该对象调用函数
  3. 删除该属性

基础版本实现

Function.prototype.myCall = function (context, ...args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

处理边界情况

Function.prototype.myCallEnhanced = function (context, ...args) {
  // 处理undefined和null
  if (context == null) {
    context = globalThis;
  }

  // 原始值需要转换为对象,否则不能添加属性
  const contextType = typeof context;
  if (contextType === 'string' ||
    contextType === 'number' ||
    contextType === 'boolean' ||
    contextType === 'symbol' ||
    contextType === 'bigint') {
    context = Object(context); // 转换为包装对象
  }

  // 使用更安全的Symbol作为key
  const fnKey = Symbol('fn');
  context[fnKey] = this;

  try {
    const result = context[fnKey](...args);
    return result;
  } finally {
    // 确保总是删除临时属性
    delete context[fnKey];
  }
};

完整实现与性能优化

Function.prototype.myCallFinal = function (context = globalThis, ...args) {
  // 1. 类型检查:确保调用者是函数
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myCallFinal called on non-function');
  }

  // 2. 处理Symbol和BigInt(ES6+)
  const contextType = typeof context;
  let finalContext = context;

  // 3. 处理原始值(非严格模式下的自动装箱)
  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    // Symbol不能通过new创建,使用Object
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    // BigInt不能通过new创建,使用Object
    finalContext = Object(context);
  }
  // null和undefined已经通过默认参数处理

  // 4. 使用Symbol创建唯一key,避免属性冲突
  const fnSymbol = Symbol('callFn');

  // 5. 将函数绑定到上下文对象
  // 使用Object.defineProperty确保属性可配置
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数并获取结果
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理临时属性
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 如果上下文不可配置,忽略错误
      console.warn('无法删除临时属性:', error.message);
    }
  }

  return result;
};

apply 方法的实现

apply 的基本使用

apply 和 call 的功能基本相同,唯一的区别在于参数的传递方式:

  • call 接受参数列表
  • apply 接受参数数组
function sum(a, b, c) {
  return a + b + c;
}
// apply:参数以数组形式传递
sum.apply(null, [1, 2, 3]);

基础版本实现

Function.prototype.myCall = function (context, args) {
  // 如果context是null或undefined,则绑定到全局对象
  if (context == null) {
    context = globalThis;
  }
  // 给context对象添加一个临时属性,值为当前函数
  const fnKey = Symbol('fn'); // 使用Symbol避免属性名冲突
  context[fnKey] = this; // this指向调用myCall的函数
  // 使用context对象调用函数
  const result = context[fnKey](...args);
  // 删除临时属性
  delete context[fnKey];
  return result;
};

完整实现与性能优化

Function.prototype.myApply = function (context = globalThis, argsArray) {
  // 1. 类型检查
  if (typeof this !== 'function') {
    throw new TypeError('Function.prototype.myApply called on non-function');
  }

  // 2. 参数处理:确保argsArray是数组或类数组对象
  let args = [];
  if (argsArray != null) {
    // 检查是否为数组或类数组
    if (typeof argsArray !== 'object' ||
      (typeof argsArray.length !== 'number' && argsArray.length !== undefined)) {
      throw new TypeError('第二个参数必须是数组或类数组对象');
    }

    // 将类数组转换为真实数组
    if (!Array.isArray(argsArray)) {
      args = Array.from(argsArray);
    } else {
      args = argsArray;
    }
  }

  // 3. 使用Symbol作为唯一key
  const fnSymbol = Symbol('applyFn');

  // 4. 处理原始值(与call相同)
  const contextType = typeof context;
  let finalContext = context;

  if (contextType === 'string') {
    finalContext = new String(context);
  } else if (contextType === 'number') {
    finalContext = new Number(context);
  } else if (contextType === 'boolean') {
    finalContext = new Boolean(context);
  } else if (contextType === 'symbol') {
    finalContext = Object(context);
  } else if (contextType === 'bigint') {
    finalContext = Object(context);
  }

  // 5. 绑定函数到上下文
  Object.defineProperty(finalContext, fnSymbol, {
    value: this,
    configurable: true,
    writable: true,
    enumerable: false
  });

  // 6. 执行函数
  let result;
  try {
    result = finalContext[fnSymbol](...args);
  } finally {
    // 7. 清理
    try {
      delete finalContext[fnSymbol];
    } catch (error) {
      // 忽略删除错误
    }
  }

  return result;
};

bind 方法的实现

bind 的基本使用

bind 方法创建一个新的函数,当这个新函数被调用时,它的 this 值会被绑定到指定的对象,并且可以预先传入部分参数。

function greet(greeting, name) {
  console.log(`${greeting}, ${name}! 我是${this.role}`);
}

const context = { role: '管理员' };

// bind:创建新函数,稍后执行
const boundGreet = greet.bind(context, '你好');
boundGreet('李四'); 

bind 的核心特性:

  1. 返回一个新函数
  2. 可以预设参数(柯里化)
  3. 绑定this值
  4. 支持new操作符(特殊情况)

基础版本实现

Function.prototype.myBind = function (context, ...bindArgs) {
  const fn = this;
    return function(...newArgs) {
        return fn.apply(context, [...args, ...newArgs]);
    };
};

处理 new 操作符的特殊情况

Function.prototype.myBindEnhanced = function (context = globalThis, ...bindArgs) {
  const originalFunc = this;

  if (typeof originalFunc !== 'function') {
    throw new TypeError('Function.prototype.myBindEnhanced called on non-function');
  }

  // 内部函数,用于判断是否被new调用
  const boundFunc = function (...callArgs) {
    // 关键判断:this instanceof boundFunc
    // 如果使用new调用,this会是boundFunc的实例
    const isConstructorCall = this instanceof boundFunc;

    // 确定最终的上下文
    // 如果是构造函数调用,使用新创建的对象作为this
    // 否则使用绑定的context
    const finalContext = isConstructorCall ? this : Object(context);

    // 合并参数
    const finalArgs = bindArgs.concat(callArgs);

    // 执行原函数
    // 如果原函数有返回值,需要特殊处理
    const result = originalFunc.apply(finalContext, finalArgs);

    // 构造函数调用的特殊处理
    // 如果原函数返回一个对象,则使用该对象
    // 否则返回新创建的对象(this)
    if (isConstructorCall) {
      if (result && (typeof result === 'object' || typeof result === 'function')) {
        return result;
      }
      return this;
    }

    return result;
  };

  // 维护原型链
  // 方法1:直接设置prototype(有缺陷)
  // boundFunc.prototype = originalFunc.prototype;

  // 方法2:使用空函数中转(推荐)
  const F = function () { };
  F.prototype = originalFunc.prototype;
  boundFunc.prototype = new F();
  boundFunc.prototype.constructor = boundFunc;

  // 添加一些元信息(可选)
  boundFunc.originalFunc = originalFunc;
  boundFunc.bindContext = context;
  boundFunc.bindArgs = bindArgs;

  return boundFunc;
};

完整实现与性能优化

Function.prototype.myBindFinal = (function () {
  // 使用闭包保存Slice方法,提高性能
  const ArraySlice = Array.prototype.slice;

  // 空函数,用于原型链维护
  function EmptyFunction() { }

  return function myBindFinal(context = globalThis, ...bindArgs) {
    const originalFunc = this;

    // 严格的类型检查
    if (typeof originalFunc !== 'function') {
      throw new TypeError('Function.prototype.bind called on non-function');
    }

    // 处理原始值的上下文(非严格模式)
    let boundContext = context;
    const contextType = typeof boundContext;

    // 原始值包装(与call/apply保持一致)
    if (contextType === 'string') {
      boundContext = new String(boundContext);
    } else if (contextType === 'number') {
      boundContext = new Number(boundContext);
    } else if (contextType === 'boolean') {
      boundContext = new Boolean(boundContext);
    } else if (contextType === 'symbol') {
      boundContext = Object(boundContext);
    } else if (contextType === 'bigint') {
      boundContext = Object(boundContext);
    }

    // 创建绑定函数
    const boundFunction = function (...callArgs) {
      // 判断是否被new调用
      const isConstructorCall = this instanceof boundFunction;

      // 确定最终上下文
      let finalContext;
      if (isConstructorCall) {
        // new调用:忽略绑定的context,使用新实例
        finalContext = this;
      } else if (boundContext == null) {
        // 非严格模式:使用全局对象
        finalContext = globalThis;
      } else {
        // 普通调用:使用绑定的context
        finalContext = boundContext;
      }

      // 合并参数
      const allArgs = bindArgs.concat(callArgs);

      // 调用原函数
      const result = originalFunc.apply(finalContext, allArgs);

      // 处理构造函数调用的返回值
      if (isConstructorCall) {
        // 如果原函数返回对象,则使用该对象
        if (result && (typeof result === 'object' || typeof result === 'function')) {
          return result;
        }
        // 否则返回新创建的实例
        return this;
      }

      return result;
    };

    // 维护原型链 - 高性能版本
    // 避免直接修改boundFunction.prototype,使用中间函数
    if (originalFunc.prototype) {
      EmptyFunction.prototype = originalFunc.prototype;
      boundFunction.prototype = new EmptyFunction();
      // 恢复constructor属性
      boundFunction.prototype.constructor = boundFunction;
    } else {
      // 处理没有prototype的情况(如箭头函数)
      boundFunction.prototype = undefined;
    }

    // 添加不可枚举的原始函数引用(用于调试)
    Object.defineProperty(boundFunction, '__originalFunction__', {
      value: originalFunc,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 添加不可枚举的绑定信息
    Object.defineProperty(boundFunction, '__bindContext__', {
      value: boundContext,
      enumerable: false,
      configurable: true,
      writable: true
    });

    Object.defineProperty(boundFunction, '__bindArgs__', {
      value: bindArgs,
      enumerable: false,
      configurable: true,
      writable: true
    });

    // 设置适当的函数属性
    Object.defineProperty(boundFunction, 'length', {
      value: Math.max(0, originalFunc.length - bindArgs.length),
      enumerable: false,
      configurable: true,
      writable: false
    });

    Object.defineProperty(boundFunction, 'name', {
      value: `bound ${originalFunc.name || ''}`.trim(),
      enumerable: false,
      configurable: true,
      writable: false
    });

    return boundFunction;
  };
})();

面试常见问题与解答

问题1:手写call的核心步骤是什么?

  1. 步骤1: 将函数设为上下文对象的属性
  2. 步骤2: 执行该函数
  3. 步骤3: 删除该属性
  4. 步骤4: 返回函数执行结果
  5. 关键点:
    • 使用Symbol避免属性名冲突
    • 处理null/undefined上下文
    • 处理原始值上下文
    • 使用展开运算符处理参数

问题2:bind如何处理new操作符?

  1. 通过 this instanceof boundFunction 判断是否被new调用
  2. 如果是new调用,忽略绑定的上下文,使用新创建的对象作为this
  3. 需要正确设置boundFunction的原型链,以支持instanceof
  4. 如果原构造函数返回对象,则使用该对象,否则返回新实例

问题3:call、apply、bind的性能差异?

  1. call通常比apply快,因为apply需要处理数组参数
  2. bind创建新函数有开销,但多次调用时比重复call/apply高效

结语

通过深入理解call、apply、bind的实现原理,我们不仅能更好地回答面试问题,还能在实际开发中编写出更优雅、更高效的JavaScript代码。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!

Next.js 16 + Supabase + Vercel:SmartChat 全栈 AI 应用架构实战

前言

全栈 AI 应用怎么选技术栈?这个问题没有标准答案,但 SmartChat 的选择——Next.js 16 + Supabase + Vercel——是一套经过验证的高效组合。本文从架构角度拆解这套技术栈的设计思路。

🔗 项目地址:smartchat.nofx.asia/

微信图片_20260212194724_46_236.png

一、为什么是 Next.js 16?

SmartChat 使用 Next.js 16 的 App Router,充分利用了以下特性:

Server Components: 仪表盘页面使用 Server Components 直接在服务端查询数据库,减少客户端 JS 体积。

API Routes(Serverless): 所有后端逻辑通过 API Routes 实现,无需维护独立的后端服务。

src/app/api/
├── chat/          # 聊天接口(SSE 流式)
├── bots/          # 机器人 CRUD
├── upload/        # 文档上传与向量化
└── conversations/ # 对话管理

Turbopack: 开发环境使用 Turbopack,热更新速度显著提升。

关键优势:前后端同仓库、同语言(TypeScript)、同部署,极大降低了开发和运维复杂度。

二、Supabase:不只是数据库

SmartChat 用 Supabase 承担了多个角色:

2.1 PostgreSQL 数据库 + pgvector

-- 业务数据和向量数据在同一个库
CREATE TABLE document_chunks (
  id uuid PRIMARY KEY,
  bot_id uuid REFERENCES bots(id) ON DELETE CASCADE,
  content text,
  embedding vector(512),  -- pgvector 向量列
  metadata jsonb
);

-- IVFFlat 索引加速向量检索
CREATE INDEX ON document_chunks
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

2.2 用户认证(Supabase Auth)

内置邮箱/密码、OAuth 登录,无需自建认证系统。

2.3 行级安全(RLS)

-- 用户只能访问自己的机器人
CREATE POLICY "Users can only access own bots" ON bots
  FOR ALL USING (auth.uid() = user_id);

-- 匿名访客可以查看公开的机器人配置
CREATE POLICY "Public bot access" ON bots
  FOR SELECT USING (is_public = true);

RLS 确保了多租户数据隔离,同时通过 Service Role 客户端允许匿名访客与机器人对话。

2.4 文件存储

文档上传使用 Supabase Storage,统一管理。

SmartChat Dashboard转存失败,建议直接上传图片文件

三、可嵌入组件设计

SmartChat 的一大亮点是一行代码嵌入任何网站

<script src="https://your-domain.com/embed.js" data-bot-id="xxx"></script>

实现原理:

// embed.js 核心逻辑
(function() {
  const botId = document.currentScript.getAttribute('data-bot-id');

  // 创建 iframe 容器
  const iframe = document.createElement('iframe');
  iframe.src = `https://your-domain.com/chat/${botId}?embed=true`;
  iframe.style.cssText = 'position:fixed;bottom:20px;right:20px;...';

  // 创建触发按钮
  const button = document.createElement('div');
  button.onclick = () => iframe.classList.toggle('visible');

  document.body.appendChild(button);
  document.body.appendChild(iframe);
})();

通过 iframe 隔离样式和脚本,避免与宿主网站冲突。聊天界面的颜色、头像、欢迎语都可以在后台自定义。

微信图片_20260212194756_49_236.png

微信图片_20260212194810_51_236.png

四、SSE 流式响应架构

SmartChat 使用 Server-Sent Events 实现实时流式输出:

// API Route: /api/chat
export async function POST(req: Request) {
  const { message, botId, conversationId } = await req.json();

  // 1. 向量检索相关文档
  const relevantDocs = await searchDocuments(message, botId);

  // 2. 构建带上下文的 Prompt
  const messages = await buildMessages(conversationId, relevantDocs);

  // 3. 流式调用 LLM
  const stream = await streamChat({ provider, model, messages });

  // 4. 返回 SSE 流
  return new Response(
    new ReadableStream({
      async start(controller) {
        let fullResponse = '';
        for await (const chunk of stream) {
          const text = extractText(chunk);
          fullResponse += text;
          controller.enqueue(`data: ${JSON.stringify({ text })}\n\n`);
        }
        // 5. 流结束后,附加来源信息
        controller.enqueue(`data: ${JSON.stringify({
          sources: relevantDocs
        })}\n\n`);
        controller.close();

        // 6. 异步保存完整回复到数据库
        await saveMessage(conversationId, fullResponse, relevantDocs);
      }
    }),
    { headers: { 'Content-Type': 'text/event-stream' } }
  );
}

流程:向量检索 → 构建 Prompt → 流式生成 → 实时推送 → 保存记录。

五、Vercel 一键部署

SmartChat 的 Serverless 架构天然适合 Vercel 部署:

  • 零服务器管理:API Routes 自动变成 Serverless Functions
  • 全球 CDN:静态资源自动分发
  • 自动扩缩容:流量高峰自动扩容,空闲时零成本
  • 环境变量管理:在 Vercel Dashboard 配置 API Keys 等敏感信息

部署流程:Fork 仓库 → 连接 Vercel → 配置环境变量 → 部署完成。

六、性能优化要点

  • React 19 + Turbopack:开发体验和构建速度大幅提升
  • Server Components:减少客户端 JS 体积
  • 流式渲染:用户无需等待完整回复
  • IVFFlat 索引:向量检索毫秒级响应
  • 批量写入:文档分块后每 10 条一批插入,避免超时

总结

Next.js + Supabase + Vercel 这套组合的核心优势是简单:一个仓库、一种语言、一键部署。对于中小团队做 AI 应用,这可能是目前投入产出比最高的技术栈选择。SmartChat 是这套架构的一个完整实践案例。

🔗 项目地址:smartchat.nofx.asia/,MIT 开源协议,支持一键部署到 Vercel。

文件16进制查看器核心JS实现

文件16进制查看器核心JS实现

本文将介绍基于 Vue 3 和 Nuxt 3 实现的“文件16进制查看器”的核心技术方案。该工具主要用于在浏览器端直接查看任意文件(包括二进制文件)的十六进制编码,所有文件处理均在前端完成,不涉及后端上传。

在线工具网址:see-tool.com/file-hex-vi…
工具截图:
在这里插入图片描述

1. 核心工具函数 (utils/file-hex-viewer.js)

我们将核心的文件处理和格式化逻辑封装在 utils/file-hex-viewer.js 中,主要包括文件大小格式化、二进制转换十六进制字符串以及文件名生成。

1.1 文件大小格式化 (formatFileSize)

用于将字节数转换为人类可读的格式(如 KB, MB)。

export function formatFileSize(bytes, units = ['Bytes', 'KB', 'MB', 'GB', 'TB']) {
  if (!Number.isFinite(bytes) || bytes < 0) return `0 ${units[0] || 'Bytes'}`
  if (bytes === 0) return `0 ${units[0] || 'Bytes'}`

  const k = 1024
  const index = Math.floor(Math.log(bytes) / Math.log(k))
  const value = Math.round((bytes / Math.pow(k, index)) * 100) / 100
  const unit = units[index] || units[units.length - 1] || 'Bytes'
  return `${value} ${unit}`
}

1.2 二进制转十六进制 (bytesToHex)

这是本工具的核心转换函数。它接收一个 Uint8Array,并根据传入的 format 参数(支持 spacenospaceuppercase)生成对应的十六进制字符串。对于 space 格式,每16个字节会自动换行,方便阅读。

export function bytesToHex(uint8Array, format = 'space') {
  if (!uint8Array || !uint8Array.length) return ''
  const useUppercase = format === 'uppercase'
  const useSpace = format === 'space'
  let hexString = ''

  for (let i = 0; i < uint8Array.length; i++) {
    // 将每个字节转换为2位十六进制字符串
    let hex = uint8Array[i].toString(16).padStart(2, '0')
    
    if (useUppercase) {
      hex = hex.toUpperCase()
    }
    
    if (useSpace) {
      hexString += `${hex} `
      // 每16个字节插入一个换行符
      if ((i + 1) % 16 === 0) {
        hexString += '\n'
      }
    } else {
      hexString += hex
    }
  }

  return hexString.trim()
}

1.3 导出文件名生成 (buildHexFileName)

根据原文件名和当前的格式设置,生成导出文件的名称(后缀为 .hex.HEX)。

export function buildHexFileName(originalName, format = 'space') {
  if (!originalName) return `file${format === 'uppercase' ? '.HEX' : '.hex'}`
  const lastDot = originalName.lastIndexOf('.')
  const baseName = lastDot > 0 ? originalName.slice(0, lastDot) : originalName
  const extension = format === 'uppercase' ? '.HEX' : '.hex'
  return `${baseName}${extension}`
}

2. 文件读取与处理逻辑

在前端实现十六进制查看器的核心是利用 HTML5 的 FileReader API 读取文件内容为 ArrayBuffer,然后转换为 Uint8Array 进行处理。

const processFile = (file) => {
  const reader = new FileReader()
  
  reader.onload = (event) => {
    try {
      const buffer = event.target.result
      const bytes = new Uint8Array(buffer)
      // 调用工具函数生成 Hex 字符串
      const hex = bytesToHex(bytes, 'space') 
      // 更新视图...
    } catch (error) {
      console.error('Process failed:', error)
    }
  }
  
  reader.onerror = () => {
    console.error('Read error')
  }
  
  // 读取文件为 ArrayBuffer
  reader.readAsArrayBuffer(file)
}

3. 导出与下载功能

为了让用户可以将十六进制编码保存到本地,我们利用 Blob 对象和 URL.createObjectURL 创建临时的下载链接,实现纯前端下载。

const downloadHexFile = (hexContent, originalName, format) => {
  if (!hexContent) return

  const fileName = buildHexFileName(originalName, format)
  // 创建包含 Hex 内容的 Blob
  const blob = new Blob([hexContent], { type: 'text/plain' })
  const url = URL.createObjectURL(blob)
  
  // 创建临时链接并触发下载
  const link = document.createElement('a')
  link.href = url
  link.download = fileName
  document.body.appendChild(link)
  link.click()
  
  // 清理
  document.body.removeChild(link)
  URL.revokeObjectURL(url)
}

总结

该方案的核心在于通过 utils/file-hex-viewer.js 封装纯粹的格式化和转换逻辑,并结合浏览器原生的 FileReaderBlob API 完成文件的读取与导出,实现了一个轻量级且高效的纯前端文件十六进制查看工具。

Vben Admin管理系统集成微前端wujie-(三)终

  1. # Vben Admin管理系统集成qiankun微服务(一)
  2. # Vben Admin管理系统集成qiankun微服务(二)

一、前言

本篇是vben前端框架集成微服务的第3篇,前段时间写了vue-vben-admin集成qiankun的两篇文章,收到了大家不少建议,文章还遗留了一个问题就是多tab标签不支持状态保持,借助AI虽然也实现的相应方案,但是对vben的package包修改内容较多(后续同步主框架较为繁琐),并且修改代码健状性不好评估。抱歉暂停了进一步完善实现方案,目前先保持基本功能是ok。

近期也尝试wujie微前端框架发现能满足我当前的所有诉求,所以有了本篇的文章内容,前两篇文章的功能和问题在本文中都已支持,选择wujie原因是支持以下两个功能:

  • 天然支持保活模式alive=true,与vben中route中Keeplive参数绑定,能支持状态保持的配置。
  • wujie实现逻辑是iframe框架模式,对子应改造较小,如果不要支持主应用传参子应用可以不用改造或少量改造。

下面分步实施集成功能:

二、主应用调整

1.安装wujie和wujie-vue3

# 安装wujie
pnpm i wujie
# 安装wujie-vue3
pnpm i wujie-vue3

2. 清除沙箱数据实现

主应用src下添加wujie文件夹并添加index.ts文件,两个函数实现功能是清理沙箱缓存数据,保证在”退出登录重新打开“样式不会异常,refreshApp函数为后续单个页签关闭提供备用支持。 index.ts,文件内容如下:

interface HTMLIframeElementWithContentWindow extends HTMLIFrameElement {
  contentWindow: Window;
}

// refreshApp 主应用可以通过下述方法,主动清除指定子应用的沙箱缓存
const refreshApp = (name = '') => {
  if (!name) {
    console.error('refreshApp方法必须传入子应用的name属性');
    return;
  }

  // 这里的window应该是顶级窗口,也就是主应用的window
  const SUB_FRAME = window.document.querySelector(
    `iframe[name=${name}]`,
  ) as HTMLIframeElementWithContentWindow;

  if (!SUB_FRAME) {
    console.warn(`未找到${name}子应用,跳过刷新`);
    return;
  }

  const SUB_WINDOW = SUB_FRAME.contentWindow;
  const SUB_IDMAP = SUB_WINDOW.__WUJIE?.inject?.idToSandboxMap; // 沙箱Map对象
  SUB_IDMAP.clear();
};

// 主应用中清除所有已激活的子应用沙箱缓存
const refreshAllApp = () => {
  // 找到所有无界子应用的iframe
  const ALL_SUB_IFRAME = window.document.querySelectorAll(
    'iframe[data-wujie-flag]',
  );

  if (ALL_SUB_IFRAME.length === 0) {
    console.warn('未找到任何子应用,跳过刷新');
    return;
  }

  // 拿到这些iframe里面的contentWindow
  const ALL_SUB_WINDOW = [...ALL_SUB_IFRAME].map(
    (v) => (v as HTMLIframeElementWithContentWindow).contentWindow,
  );

  // 依次执行清除
  ALL_SUB_WINDOW.forEach((v) => v.__WUJIE?.inject?.idToSandboxMap?.clear());
};

export { refreshAllApp, refreshApp };

主应用/src/layouts/basic.vue 程序主界面,在头部引入上述文件并在相应位置调用清除沙箱方法

# 引用
import { refreshAllApp } from '#/wujie/index';

# 退出时清理
// logout
async function handleLogout() {
  await authStore.logout(false);
  refreshAllApp();
}

3. 添加微服务通用页面wujie.vue

在主应用 /apps/web-caipu/src/views/_core下添加wujie.vue页面,页面内容如:

<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';

import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';

import WujieVue from 'wujie-vue3';

const useStore = useUserStore();
const accessStore = useAccessStore();
const route = useRoute();

// props通信
const props = ref({
  userinfo: useStore.userInfo,
  token: accessStore.accessToken,
  preferences,
});
// 加时缀是强制刷新
const appUrl = ref(`http://localhost:5667/app${route.path}?t=${Date.now()}`);
const keepLive = route.meta?.keepAlive;
</script>
<template>
  <div class="sub-app-container">
    <WujieVue
      width="100%"
      height="100%"
      :name="appUrl"
      :url="appUrl"
      :alive="keepLive"
      :props="props"
    />
  </div>
</template>
<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  background: white;
}
</style>

<style scoped>
.sub-app-container {
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: white;
  border-radius: 8px;
}
</style>

聪明的你,一定知道实现的逻辑。其中子应用的地址测试写localhost:5667,后面会集成配置文件中,至此主应用改造完成。

三、子应用改造

子应用基本不用改,只要改/Users/wgh/code/caipu-vben-admin/apps-micro/web-antd/src/bootstrap.ts文件即可

image.png 在49行添加如下代码,代码不用解释,之前一样的实现逻辑。


 // 初使化存储之后赋值,避免路由判断跳转到登录页
  if (window.__POWERED_BY_WUJIE__) {
    // props 接收
    const props = window.$wujie?.props; // {data: xxx, methods: xxx}
    const useStore = useUserStore();
    const accessStore = useAccessStore();
    useStore.setUserInfo(props.userInfo);
    accessStore.setAccessToken(props.token);
    updatePreferences(props.preferences);
    // window.$wujie?.bus.$on('wujie-theme-update', (theme: any) => {
    //   alert('wujie-theme-update');
    //   updatePreferences(theme);
    // });
    window.addEventListener('wujie-theme-update', (theme: any) => {
      updatePreferences(theme.detail);
    });
  }

四。新增路由配置

在主应用路由中配置子应用一个测试路由 /app/basic/test,

image.png

为测试在子应用状态保持,我在页面中添加一个测试文本框 ,测试内容不会随着切tab页签而重新加载,浏览器的前进后退也不会出错。

image.png

上述功能已集成在前端程序里,如果我的文章对你有帮助,感谢给我点个🌟

Anthony Fu 的 Vue3 开发规范完整解读

Anthony Fu 的 Vue3 开发规范完整解读

本文基于 antfu/skills 仓库整理翻译,全面解析 Anthony Fu 在 Vue 3 生态中的编码规范、最佳实践和工具链推荐。作为 Vue 核心团队成员、Vite 团队成员以及众多开源项目的作者(VueUse、UnoCSS、Vitest、Slidev 等),Anthony 的开发理念深刻影响了现代 Vue 开发生态。

第一部分:编码实践与工具链

代码组织原则

单一职责原则

保持文件和函数专注于单一职责。当文件超过 200-300 行时,考虑拆分:

// ❌ 避免:一个文件包含所有逻辑
// UserManager.ts (800 lines)
export class UserManager {
  validateUser() { /* 50 lines */ }
  fetchUserData() { /* 100 lines */ }
  updateUserProfile() { /* 150 lines */ }
  // ...
}

// ✅ 推荐:按职责拆分
// validation.ts
export function validateUser(user: User) { /* ... */ }

// api.ts
export function fetchUserData(id: string) { /* ... */ }

// profile.ts
export function updateUserProfile(data: ProfileData) { /* ... */ }

类型与常量分离

// types.ts
export interface User {
  id: string
  name: string
  role: UserRole
}

export type UserRole = 'admin' | 'user' | 'guest'

// constants.ts
export const DEFAULT_PAGE_SIZE = 20
export const MAX_RETRIES = 3
export const API_ENDPOINTS = {
  users: '/api/users',
  posts: '/api/posts',
} as const

// user-service.ts
import type { User, UserRole } from './types'
import { API_ENDPOINTS } from './constants'

export async function fetchUsers(): Promise<User[]> {
  const response = await fetch(API_ENDPOINTS.users)
  return response.json()
}

运行时环境标注

编写同构代码时,为环境特定的代码添加明确的注释:

// ✅ 明确标注环境依赖
// @env browser
export function getWindowSize() {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
  }
}

// @env node
export function readConfigFile() {
  return fs.readFileSync('./config.json', 'utf-8')
}

// ✅ 同构代码无需标注
export function formatDate(date: Date): string {
  return date.toISOString()
}

TypeScript 最佳实践

显式返回类型

// ❌ 避免:隐式返回类型
export function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 推荐:显式返回类型
export function calculateTotal(items: Item[]): number {
  return items.reduce((sum, item) => sum + item.price, 0)
}

// ✅ 复杂类型提取为类型别名
export type AsyncResult<T> = Promise<{ data: T; error: null } | { data: null; error: Error }>

export function fetchData<T>(url: string): AsyncResult<T> {
  // ...
}

避免复杂内联类型

// ❌ 避免:复杂内联类型
function processUsers(
  users: Array<{
    id: string
    profile: {
      name: string
      email: string
      settings: {
        theme: 'light' | 'dark'
        notifications: boolean
      }
    }
  }>
) {
  // ...
}

// ✅ 推荐:提取类型定义
interface UserSettings {
  theme: 'light' | 'dark'
  notifications: boolean
}

interface UserProfile {
  name: string
  email: string
  settings: UserSettings
}

interface User {
  id: string
  profile: UserProfile
}

function processUsers(users: User[]) {
  // ...
}

注释哲学

解释"为什么"而非"怎么做"

// ❌ 避免:无意义的注释
// 循环遍历用户数组
users.forEach(user => {
  // 打印用户名
  console.log(user.name)
})

// ✅ 推荐:解释为什么这样做
// 使用 setTimeout 0 延迟执行,确保 DOM 更新完成后再计算高度
setTimeout(() => {
  const height = element.offsetHeight
}, 0)

// ✅ 解释非直观的业务逻辑
// 价格计算需要先扣除折扣,再加税费,顺序不能颠倒
// 因为税费基于折后价计算(符合当地税法要求)
const finalPrice = (price - discount) * (1 + taxRate)

测试规范(Vitest)

文件组织

src/
  utils/
    format.ts          # 源代码
    format.test.ts     # 测试文件
  components/
    Button.vue
    Button.test.ts

测试结构

// format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency, formatDate } from './format'

describe('formatCurrency', () => {
  it('should format USD correctly', () => {
    expect(formatCurrency(1234.56, 'USD')).toBe('$1,234.56')
  })

  it('should handle zero', () => {
    expect(formatCurrency(0, 'USD')).toBe('$0.00')
  })

  it('should round to 2 decimal places', () => {
    expect(formatCurrency(1.234, 'USD')).toBe('$1.23')
  })
})

describe('formatDate', () => {
  it('should match snapshot', () => {
    const date = new Date('2024-01-15T10:30:00Z')
    expect(formatDate(date)).toMatchSnapshot()
  })
})

工具链速查

@antfu/ni - 通用包管理器命令
命令 npm yarn pnpm bun
ni npm install yarn install pnpm install bun install
nr dev npm run dev yarn run dev pnpm run dev bun run dev
nu npm update yarn upgrade pnpm update bun update
nun lodash npm uninstall lodash yarn remove lodash pnpm remove lodash bun remove lodash
nci npm ci yarn install --frozen-lockfile pnpm install --frozen-lockfile bun install --frozen-lockfile
nlx vitest npx vitest yarn dlx vitest pnpm dlx vitest bunx vitest
TypeScript 配置标准
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ESNext", "DOM", "DOM.Iterable"],
    "jsx": "preserve",
    "strict": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "noUncheckedIndexedAccess": true,
    "paths": {
      "~/*": ["./src/*"]
    }
  }
}

关键配置说明:

  • moduleResolution: "bundler" - 适配 Vite/Rollup 等现代打包工具
  • noUncheckedIndexedAccess: true - 索引访问返回 T | undefined,更安全
  • strict: true - 启用所有严格类型检查
ESLint 配置
# 安装
pnpm add -D @antfu/eslint-config eslint

# 运行
pnpm run lint --fix
// eslint.config.js
import antfu from '@antfu/eslint-config'

export default antfu({
  vue: true,
  typescript: true,
  formatters: {
    css: true,
    html: true,
    markdown: true,
  },
})
Git Hooks 配置
# 安装
pnpm add -D simple-git-hooks lint-staged
// package.json
{
  "simple-git-hooks": {
    "pre-commit": "pnpm lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": "eslint --fix"
  }
}
pnpm Catalogs 最佳实践
# pnpm-workspace.yaml
catalogs:
  # 生产依赖
  prod:
    vue: ^3.5.0
    pinia: ^2.2.0
  
  # 内联依赖(会被打包)
  inlined:
    lodash-es: ^4.17.21
  
  # 开发依赖
  dev:
    vitest: ^2.0.0
    typescript: ^5.6.0
  
  # 前端特定依赖
  frontend:
    unocss: ^0.63.0
// package.json
{
  "dependencies": {
    "vue": "catalog:prod",
    "lodash-es": "catalog:inlined"
  },
  "devDependencies": {
    "vitest": "catalog:dev",
    "unocss": "catalog:frontend"
  }
}

第二部分:Vue 3 核心规范

基于 Vue 3.5,优先使用 TypeScript 和 <script setup>

偏好设定

场景 推荐方案 原因
语言选择 TypeScript 类型安全、更好的 IDE 支持
脚本格式 <script setup lang="ts"> 更简洁的语法、更好的性能
响应式选择 shallowRef > ref 大多数场景足够用,性能更好
API 风格 Composition API 更好的逻辑复用和类型推导
Props 解构 ❌ 不推荐 会丢失响应式

标准组件模板

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import type { ComponentPublicInstance } from 'vue'

// Props 定义
interface Props {
  title: string
  count?: number
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  disabled: false,
})

// Emits 定义
interface Emits {
  update: [value: number]
  submit: [data: { name: string }]
}

const emit = defineEmits<Emits>()

// Model 双向绑定
const modelValue = defineModel<string>({ required: true })

// 响应式状态
const isLoading = ref(false)
const items = ref<Item[]>([])

// 计算属性
const displayTitle = computed(() => {
  return props.disabled ? `${props.title} (已禁用)` : props.title
})

// 侦听器
watch(() => props.count, (newVal, oldVal) => {
  console.log(`Count changed from ${oldVal} to ${newVal}`)
})

// 生命周期
onMounted(() => {
  console.log('Component mounted')
})

// 方法
function handleClick() {
  emit('update', props.count + 1)
}

// 暴露给父组件(defineExpose)
defineExpose({
  focus: () => {
    // 暴露的方法
  },
})
</script>

<template>
  <div>
    <h1>{{ displayTitle }}</h1>
    <button @click="handleClick" :disabled="disabled">
      Count: {{ count }}
    </button>
  </div>
</template>

<style scoped>
/* scoped 样式 */
</style>

关键导入速查

// 核心响应式 API
import {
  ref,           // 深层响应式
  shallowRef,    // 浅层响应式(推荐)
  reactive,      // 深层响应式对象
  shallowReactive, // 浅层响应式对象
  readonly,      // 只读代理
  computed,      // 计算属性
  watch,         // 侦听器
  watchEffect,   // 副作用侦听器
} from 'vue'

// 生命周期钩子
import {
  onMounted,
  onUpdated,
  onUnmounted,
  onBeforeMount,
  onBeforeUpdate,
  onBeforeUnmount,
} from 'vue'

// 组件通信
import {
  defineProps,
  defineEmits,
  defineModel,
  defineExpose,
  defineSlots,
  provide,
  inject,
} from 'vue'

// 工具函数
import {
  nextTick,      // 等待 DOM 更新
  toRef,         // 转换为 ref
  toRefs,        // 解构保持响应式
  unref,         // 解包 ref
  isRef,         // 判断是否 ref
  markRaw,       // 标记为非响应式
} from 'vue'

// 类型工具
import type {
  Ref,
  ComputedRef,
  ComponentPublicInstance,
  PropType,
} from 'vue'

ref vs shallowRef 性能对比

// ref - 深层响应式(递归代理所有层级)
const deepState = ref({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 任何层级的修改都会触发响应
deepState.value.user.profile.settings.theme = 'light' // ✅ 响应式

// shallowRef - 浅层响应式(只代理第一层)
const shallowState = shallowRef({
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: 'dark',
      },
    },
  },
})

// 只有整体替换才会触发响应
shallowState.value.user.profile.settings.theme = 'light' // ❌ 不会触发
shallowState.value = { ...shallowState.value } // ✅ 触发响应

// 性能建议:大部分场景使用 shallowRef 足够

第三部分:Vue 3 最佳实践与常见陷阱

响应式系统

问题 建议
ref vs reactive 如何选择? 优先使用 refref 可以存储任何类型,而 reactive 只能用于对象。ref 在重新赋值时保持响应性,reactive 不行。
什么时候用 shallowRef? 存储大型数据结构(如长列表、复杂嵌套对象)时,用 shallowRef 避免深层代理的性能开销。更新时需要整体替换对象。
如何阻止对象变成响应式? 使用 markRaw()。例如存储第三方库实例(Chart.js、Monaco Editor)时,避免不必要的代理。
多次修改 ref 会触发多次渲染吗? 不会。Vue 会将同一 tick 内的更新批量处理。如需立即看到 DOM 变化,使用 await nextTick()
ref 解包规则是什么? 在模板中自动解包({{ count }})。在 reactive 对象中自动解包(state.count)。在数组和 Map/Set 中不解包(需要 .value)。
解构 props 会丢失响应性吗? 是的。使用 toRefs(props)toRef(props, 'key') 保持响应性,或在 computed/watch 中访问 props.xxx

计算属性

问题 建议
计算属性可以有副作用吗? 不应该。计算属性应该是纯函数,只做计算和返回值。副作用应该放在 watchwatchEffect 中。
为什么计算属性是只读的? 默认只读,但可以提供 setter。推荐只读设计,修改应通过源数据。
计算属性什么时候重新计算? 只有当依赖的响应式数据变化时才重新计算(懒执行 + 缓存)。这是相比 method 的主要优势。
计算属性的条件依赖如何工作? 只追踪当前执行分支的依赖。if (flag) return a 时只追踪 flaga,不追踪 else 分支。
计算属性内使用 array.map 有性能问题吗? 有。每次重新计算都会创建新数组。考虑使用 shallowRef 存储映射结果,或在 watch 中手动更新。

侦听器

问题 建议
watch 的 getter 函数是什么? watch(() => obj.count, ...) 中的箭头函数。推荐用 getter 而非直接传对象,可以精确控制依赖。
deep: true 有性能问题吗? 有。深度侦听需要遍历对象的所有属性。只在必要时使用,或用 getter 函数精确指定依赖。
immediate: true 的执行时机是什么? 立即执行一次,此时 DOM 可能未挂载。需要访问 DOM 时注意判断。
flush 选项有什么区别? pre(默认):DOM 更新前执行。post:DOM 更新后执行。sync:同步执行(避免使用)。
如何在侦听器中访问旧值? watch(source, (newVal, oldVal) => {})。注意对象类型的 oldValnewVal 可能指向同一个引用。
watch vs watchEffect 如何选择? watchEffect:自动追踪依赖,简洁。watch:显式指定侦听源,可访问旧值,更精确。

组件通信

问题 建议
可以修改 props 吗? 不可以。Props 是单向数据流,只读。需要修改时,emit 事件或使用 defineModel
自定义事件会冒泡吗? 不会。Vue 的自定义事件不像原生 DOM 事件那样冒泡,只触发直接父组件的监听器。
组件名应该用什么格式? PascalCase(MyComponent.vue)。在模板中可以用 <MyComponent><my-component>,推荐前者。
defineExpose 何时使用? 当需要父组件通过 ref 调用子组件方法时。默认 <script setup> 不暴露任何内容。
如何获取组件实例的类型? InstanceType<typeof MyComponent>,配合 ref<InstanceType<typeof MyComponent>>()

Props 与 Emits

问题 建议
Boolean props 的转换规则? <MyComp disabled> 等价于 :disabled="true"<MyComp> 则是 undefined(除非有默认值)。
解构 props 会丢失响应性吗? 是的。const { title } = defineProps() 会丢失响应性。使用 toRefs 或直接访问 props.title
props 命名约定是什么? JS 中用 camelCase,HTML 中用 kebab-case。defineProps<{ userName: string }>()<Comp user-name="John">
emit 事件命名约定? JS 中用 camelCase,HTML 中用 kebab-case。emit('updateValue')@update-value="handler"
defineModel 的优势是什么? 简化 v-model 实现,自动生成 prop 和 emit。支持修饰符(.trim.number 等)。

模板语法

问题 建议
v-html 安全吗? 不安全。可能导致 XSS 攻击。只用于可信内容,或使用 DOMPurify 等库清理。
v-if 和 v-for 能同时用吗? Vue 3 中 v-if 优先级高于 v-for,但不推荐同时使用。应该用 computed 过滤或嵌套 template。
v-if vs v-show 如何选择? v-if:条件渲染,切换开销高。v-show:CSS 切换,初始渲染开销高。频繁切换用 v-show
key 的作用是什么? 帮助 Vue 识别节点,优化 diff 算法。列表渲染必须提供唯一 key,避免用 index。
如何绑定多个属性? v-bind="attrs" 可以一次性绑定对象的所有属性。例如 v-bind="{ id: 'foo', class: 'bar' }"

表单与 v-model

问题 建议
defineModel 的修饰符如何使用? 内置 .trim.number.lazy。自定义修饰符通过 defineModel 的第二个参数处理。
v-model 在组件上的原理? 语法糖::modelValue="value" @update:modelValue="value = $event"。多个 v-model:v-model:title
如何在 v-model 更新后访问 DOM? 使用 await nextTick(),因为 Vue 异步更新 DOM。
textarea 的 v-model 和插值的区别? <textarea v-model="text"> 正确。<textarea>{{ text }}</textarea> 不生效,textarea 不支持插值。

事件处理与修饰符

问题 建议
.once 修饰符如何工作? 事件只触发一次后自动移除监听器。@click.once="handler"
.exact 修饰符的作用? 精确匹配修饰键。@click.ctrl.exact 只在按下 Ctrl(无其他键)时触发。
.passive 和 .prevent 冲突吗? 冲突。.passive 告诉浏览器不调用 preventDefault(),两者不能同时使用。
自定义事件可以用修饰符吗? 可以,但需要在子组件中通过 defineEmits 的第二个参数手动实现验证逻辑。

生命周期

问题 建议
生命周期钩子必须同步注册吗? 是的。必须在 setup<script setup> 的同步代码中调用,不能在 setTimeoutasync 函数中。
onUpdated 钩子性能如何? 会在任何响应式数据变化导致的重新渲染后调用,可能频繁触发。谨慎使用,考虑用 watch 替代。
如何在组件外部注册生命周期? 使用 effectScope 创建作用域,在其中注册钩子。

插槽

问题 建议
插槽的作用域是什么? 默认插槽只能访问父组件的数据。作用域插槽通过 v-slot="slotProps" 接收子组件传递的数据。
defineSlots 的作用? 仅用于类型定义,帮助 TypeScript 推导插槽的 props 类型。不影响运行时。
插槽的 fallback content 是什么? <slot>默认内容</slot> 中的默认内容,当父组件不提供插槽内容时显示。
动态插槽名如何使用? v-slot:[dynamicSlotName]#[dynamicSlotName]

Provide / Inject

问题 建议
应该用什么作为 injection key? 使用 Symbol 而非字符串,避免命名冲突。export const userKey = Symbol('user')
注入的数据可以修改吗? 可以,但建议 mutations 集中在 provider 组件,通过提供修改方法而非直接暴露响应式状态。
如何为 inject 提供类型? const user = inject<User>(userKey) 或在定义 key 时指定 InjectionKey<User>

组合式函数

问题 建议
命名约定是什么? use 开头,camelCase。例如 useMouseuseFetch
返回值应该是什么? 返回包含响应式状态和方法的对象。使用 readonly() 保护内部状态。
何时使用 options 对象模式? 参数超过 2 个时推荐。useFetch(url, { method, headers, onSuccess })
组合式函数可以嵌套调用吗? 可以。一个组合式函数可以调用其他组合式函数。
// 示例:标准组合式函数
export function useMouse() {
  const x = ref(0)
  const y = ref(0)

  function update(event: MouseEvent) {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => window.addEventListener('mousemove', update))
  onUnmounted(() => window.removeEventListener('mousemove', update))

  return {
    x: readonly(x),
    y: readonly(y),
  }
}

Composition API

问题 建议
为什么用 Composition API 替代 mixin? Mixin 有命名冲突、来源不清晰、难以重用等问题。Composition API 通过函数组合解决这些问题。
Composition API 和 React Hooks 有什么区别? Vue 的 setup 只执行一次,不受闭包陷阱影响。React Hooks 每次渲染都执行,需要依赖数组。
何时仍然使用 Options API? 简单组件、团队不熟悉 Composition API、维护老代码时可以使用 Options API。

自定义指令

问题 建议
必须清理副作用吗? 是的。在 unmounted 钩子中清理事件监听器、定时器等,避免内存泄漏。
指令命名约定? v 开头。注册时用 camelCase(vFocus),使用时用 kebab-case(v-focus)。
可以在组件上使用指令吗? 可以,但不推荐。指令会应用到组件的根元素,多根元素组件会报警告。

过渡与动画

问题 建议
Transition 只能包含单个子元素吗? 是的。多个元素需要用 v-if / v-elseTransitionGroup
为什么列表项需要 key? TransitionGroup 使用 key 追踪元素移动,实现平滑的移动动画。
mode 属性的作用? out-in:旧元素先离开,新元素再进入。in-out:新元素先进入,旧元素再离开。
如何自定义动画时长? 通过 duration prop:<Transition :duration="500">{ enter: 500, leave: 800 }

KeepAlive

问题 建议
max 属性的作用? 限制缓存组件数量,超出时移除最久未访问的。<KeepAlive :max="10">
组件必须有 name 属性吗? 使用 include / exclude 时需要。<script setup> 组件名默认是文件名。
特殊生命周期钩子? onActivated(激活时)、onDeactivated(停用时)。用于处理缓存组件的状态恢复。

异步组件

问题 建议
delay 选项的作用? 延迟显示加载状态,避免加载很快时出现闪烁。默认 200ms。
hydration 策略是什么? Vue 3.5+ 支持延迟 hydration:defineAsyncComponent({ loader, hydrate: 'visible' })

TypeScript 集成

问题 建议
如何为 defineProps 提供类型? 基于类型:defineProps<{ title: string }>()。基于运行时:defineProps({ title: String })。推荐前者。
withDefaults 如何使用? withDefaults(defineProps<Props>(), { count: 0 }),为类型定义的 props 提供默认值。
如何获取组件实例类型? InstanceType<typeof MyComponent>,用于 ref 的类型标注。
// 完整示例
import MyComponent from './MyComponent.vue'

const compRef = ref<InstanceType<typeof MyComponent>>()

onMounted(() => {
  compRef.value?.focus() // 类型安全的方法调用
})

SSR 注意事项

问题 建议
如何避免跨请求状态污染? 每个请求创建新的应用实例。避免在模块顶层创建响应式状态。
服务端可以使用哪些 API? 不能用 windowdocument 等浏览器 API。生命周期只有 setuponServerPrefetch
getSSRProps 的作用? 在 SSR 时修改组件 props,常用于注入服务端数据。

性能优化

问题 建议
props 稳定性为什么重要? 子组件使用 shallowRef 时,props 引用变化会触发重新渲染。尽量保持 props 引用稳定。
何时使用虚拟滚动? 渲染超过 1000 项的列表时。使用 vue-virtual-scroller 等库。
v-once 和 v-memo 的区别? v-once:只渲染一次,永不更新。v-memo:条件性跳过更新,依赖数组未变时复用。

SFC 特性

问题 建议
如何在 scoped 样式中修改子组件? 使用 :deep() 伪类:.parent :deep(.child) { }
scoped CSS 的限制? 不影响子组件的根元素(会自动添加 scoped 属性)。深层元素需要 :deep()

插件开发

问题 建议
插件应该用 provide/inject 吗? 是的。插件通过 provide 提供功能,组件通过 inject 使用,比全局属性更灵活。
注入 key 命名约定? 使用 Symbol 避免冲突:export const myPluginKey = Symbol()
如何为插件添加类型支持? 通过模块扩展:declare module 'vue' { interface ComponentCustomProperties { $myPlugin: MyPlugin } }

第四部分:为什么选择 UnoCSS 而不是 Tailwind CSS?

核心论点:UnoCSS 是 Tailwind 的超集

UnoCSS 不是 Tailwind 的竞争者,而是增强版。通过预设系统,UnoCSS 可以 100% 兼容 Tailwind 语法

// uno.config.ts
import { defineConfig, presetWind } from 'unocss'

export default defineConfig({
  presets: [
    presetWind(), // Tailwind CSS v3 兼容
    // 或 presetWind({ version: 4 }) // Tailwind CSS v4 兼容
  ],
})

使用 presetWind 后,所有 Tailwind 类名都能正常工作:

<!-- Tailwind 语法完全兼容 -->
<div class="flex items-center justify-between p-4 bg-blue-500 text-white rounded-lg shadow-md hover:bg-blue-600 transition">
  <span class="text-xl font-bold">完全兼容</span>
</div>

UnoCSS 的独家能力

1. 纯 CSS 图标(零 JS 运行时)

UnoCSS 通过 presetIcons 支持 10 万+ Iconify 图标,编译为纯 CSS,零 JavaScript 运行时开销:

pnpm add -D @iconify-json/carbon @iconify-json/mdi
<!-- 直接用 class 引用图标,无需导入 -->
<div class="i-carbon-logo-github text-2xl" />
<div class="i-mdi-home text-red-500" />
<button class="i-carbon-arrow-right hover:i-carbon-arrow-right-filled" />

编译结果(纯 CSS):

.i-carbon-logo-github {
  display: inline-block;
  width: 1em;
  height: 1em;
  background: url("data:image/svg+xml;utf8,...") no-repeat;
  background-size: 100% 100%;
}

对比 Tailwind + React Icons:

  • Tailwind:需要导入 React/Vue 组件,增加 bundle 体积
  • UnoCSS:纯 CSS,零 JS,图标按需编译
2. 属性化模式(Attributify)

避免 class 字符串爆炸:

<!-- Tailwind:class 字符串过长 -->
<button class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-lg shadow-md transition duration-300 flex items-center gap-2">
  提交
</button>

<!-- UnoCSS:属性化模式 -->
<button 
  bg="blue-500 hover:blue-600"
  text="white"
  font="bold"
  p="y-2 x-4"
  rounded="lg"
  shadow="md"
  transition
  duration="300"
  flex
  items="center"
  gap="2"
>
  提交
</button>
3. Variant Group(变体组简写)
<!-- Tailwind:重复写 hover -->
<div class="hover:bg-red-500 hover:text-white hover:scale-105">

<!-- UnoCSS:Variant Group -->
<div class="hover:(bg-red-500 text-white scale-105)">
4. 自定义规则引擎

Tailwind 需要配置复杂的插件系统,UnoCSS 支持正则和函数定义原子类:

// uno.config.ts
import { defineConfig } from 'unocss'

export default defineConfig({
  rules: [
    // 正则匹配:自定义间距
    [/^m-(\d+)$/, ([, d]) => ({ margin: `${d}px` })],
    
    // 函数定义:自定义颜色
    ['text-brand', { color: '#3b82f6' }],
    
    // 动态值:任意单位
    [/^gap-(\d+)(px|rem|em)$/, ([, num, unit]) => ({ gap: `${num}${unit}` })],
  ],
  shortcuts: {
    // 快捷组合类
    'btn-primary': 'bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600',
  },
})
5. 编译模式(Compile Class)

将多个原子类编译为一个哈希类,减少 HTML 体积:

<!-- 开发模式:原子类 -->
<div class="flex items-center gap-4 bg-blue-500 p-4">

<!-- 生产模式:编译为单个类 -->
<div class="uno-abc123">

<style>
.uno-abc123 {
  display: flex;
  align-items: center;
  gap: 1rem;
  background: #3b82f6;
  padding: 1rem;
}
</style>

对比总结

特性 Tailwind CSS UnoCSS
基础原子类 ✅ 完整支持 ✅ 完全兼容(presetWind)
图标方案 需要额外库(React Icons 等) ✅ 内置 10 万+ 图标(纯 CSS)
属性化模式 ❌ 不支持 ✅ presetAttributify
Variant Group ❌ 不支持 hover:(bg-red text-white)
自定义规则 复杂插件系统 ✅ 正则/函数直接定义
编译模式 ❌ 不支持 ✅ 编译为哈希类
性能 JIT 编译快 ✅ 更快(Vite 原生)
生态整合 Standalone ✅ Vite/Nuxt 深度集成

与 Anthony Fu 技术栈的协同

  1. Vite 原生设计:UnoCSS 为 Vite 设计,HMR 极快
  2. Nuxt 一等公民@nuxt/unocss 开箱即用
  3. 作者生态:Anthony Fu 同时是 UnoCSS 和 Iconify 的作者,工具链深度整合

完整配置示例

// uno.config.ts
import {
  defineConfig,
  presetAttributify,
  presetIcons,
  presetTypography,
  presetUno,
  presetWebFonts,
  transformerDirectives,
  transformerVariantGroup,
} from 'unocss'

export default defineConfig({
  // 预设
  presets: [
    presetUno(), // 默认预设(类似 Tailwind)
    presetAttributify(), // 属性化模式
    presetIcons({
      scale: 1.2,
      cdn: 'https://esm.sh/',
    }),
    presetTypography(), // 排版预设
    presetWebFonts({
      fonts: {
        sans: 'Inter',
        mono: 'Fira Code',
      },
    }),
  ],

  // 转换器
  transformers: [
    transformerDirectives(), // @apply 指令
    transformerVariantGroup(), // Variant Group
  ],

  // 自定义规则
  rules: [
    ['text-brand', { color: '#3b82f6' }],
  ],

  // 快捷方式
  shortcuts: {
    'btn': 'px-4 py-2 rounded inline-block cursor-pointer',
    'btn-primary': 'btn bg-blue-500 text-white hover:bg-blue-600',
  },

  // 主题扩展
  theme: {
    colors: {
      brand: {
        primary: '#3b82f6',
        secondary: '#8b5cf6',
      },
    },
  },
})

安装命令:

pnpm add -D unocss
pnpm add -D @iconify-json/carbon @iconify-json/mdi

第五部分:配套工具链一览

工具 用途 推荐理由
Vue 3.5+ 渐进式 JavaScript 框架 Composition API、性能优化、TypeScript 支持
Nuxt 3 Vue 元框架 SSR/SSG、文件路由、服务端 API、SEO 优化
Pinia 状态管理 直观的 API、完整的 TypeScript 支持、Vue DevTools 集成
Vite 构建工具 极速 HMR、原生 ESM、Rollup 生产构建
VitePress 静态站点生成器 Vue 驱动、Markdown 扩展、主题定制
Vitest 单元测试 Vite 原生、与 Jest 兼容的 API、快速执行
UnoCSS 原子化 CSS 引擎 Tailwind 超集、纯 CSS 图标、Vite 深度集成
pnpm 包管理器 磁盘高效、严格依赖管理、monorepo 支持
VueUse 组合式函数集合 200+ 实用工具、SSR 友好、Tree-shakable
Slidev 开发者幻灯片 Markdown 编写、Vue 组件、录制功能
tsdown TypeScript 打包工具 零配置、类型声明生成、ESM/CJS 双输出
Vue Router 官方路由 嵌套路由、导航守卫、动态路由匹配

快速开始命令

# 创建 Vue 3 项目(Vite)
pnpm create vite my-vue-app --template vue-ts

# 创建 Nuxt 3 项目
pnpm dlx nuxi@latest init my-nuxt-app

# 添加 UnoCSS
pnpm add -D unocss

# 添加 VueUse
pnpm add @vueuse/core

# 添加 Pinia
pnpm add pinia

# 添加 Vitest
pnpm add -D vitest

总结

Anthony Fu 的开发规范强调:

  1. 类型安全优先:TypeScript + 显式类型定义
  2. 性能意识shallowRef > ref、避免深度侦听、虚拟滚动
  3. 工具链协同:Vite + UnoCSS + Vitest 深度整合
  4. 代码质量:单一职责、ESLint 自动化、Git Hooks
  5. 现代化实践:Composition API、<script setup>、组合式函数

通过遵循这些规范,可以构建更快、更可维护、更具扩展性的 Vue 3 应用。


参考资料:

译者注: 本文基于 antfu/skills 仓库于 2026 年 2 月的内容整理翻译,随着生态演进,部分实践可能更新,请以官方文档为准。

从原理到实践:JavaScript中的this指向,一篇就够了

从原理到实践:JavaScript中的this指向,一篇就够了

前言

最近在复习JavaScript的this指向问题,写了几个小例子来加深理解。很多同学觉得this难,其实是因为this是在函数执行时确定的,而不是定义时。这个特性导致了this指向的“善变”。

今天,就让我通过这几个代码例子,带你由浅入深掌握JavaScript中的this指向。


第一章:基础概念 - this的默认绑定

1.1 全局环境下的this

在浏览器全局环境中,this指向window对象:

var name = "windowName"; // 全局变量
var func1 = function() {
  console.log('func1');
}

console.log(this.name); // "windowName"
console.log(window.name); // "windowName"

核心知识点:

  • 全局作用域下,this === window
  • var声明的全局变量会自动挂载到window对象上

第二章:谁调用我,我就指向谁

2.1 对象方法调用

var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  }
}

a.func1(); // "Cherry"

关键理解: 这里的this指向了对象a。因为func1是由a调用的。

2.2 经典面试题 - 定时器中的this

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里会报错!
    }, 3000)
  }
}

a.func2(); // TypeError: this.func1 is not a function

为什么报错?

定时器的回调函数是由定时器内部调用的,此时this指向了window对象。而window对象上并没有func1方法(虽然有全局变量func1,但这里调用的是对象方法)。

验证一下:

var a = {
  // ... 同上
  func2: function() {
    setTimeout(function() {
      console.log(this); // window
    }, 3000)
  }
}

第三章:解决this丢失的三种方案

3.1 方案一:保存this(that = this)

// 3.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    var that = this; // 保存外层的this
    setTimeout(function() {
      that.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

原理: 利用闭包的特性,内部函数可以访问外部函数的变量。that保存了正确的this引用。

3.2 方案二:bind绑定

// 2.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); 
    }.bind(a), 3000) // bind永久绑定this为a
  }
}

a.func2(); // 3秒后输出 "Cherry" ✅

重要区别:

  • bind()不会立即执行,返回一个新函数,永久绑定this
  • call()/apply()立即执行函数,临时绑定this
// 对比演示
a.func1.call(a); // 立即执行
const boundFunc = a.func1.bind(a); // 返回绑定后的函数,不执行
boundFunc(); // 执行时this已经绑定为a

3.3 方案三:箭头函数

// 4.html 中的例子
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(() => {
      console.log(this); // a对象 ✅
      this.func1(); // "Cherry" ✅
    }, 3000)
  }
}

a.func2(); // 3秒后输出 "Cherry"

箭头函数的特点:

  • 没有自己的this
  • 继承定义时所在作用域的this
  • this是静态的,不会改变

第四章:深入理解箭头函数

4.1 箭头函数没有自己的this

// 5.html 中的例子
const func = () => {
  console.log(this); // window(在浏览器环境)
}

func(); // window

4.2 箭头函数不能作为构造函数

const func = () => {
  console.log(this);
}

new func(); // TypeError: func is not a constructor ❌

为什么? 箭头函数没有自己的this,也没有prototype属性,无法进行实例化。

4.3 关于arguments对象

const func = () => {
  console.log(arguments); // ReferenceError ❌
}

// 箭头函数也没有自己的arguments对象
// 但可以这样获取参数
const func2 = (...args) => {
  console.log(args); // [1, 2, 3] ✅
}

func2(1, 2, 3);

第五章:综合实践 - 分析一段复杂代码

让我们来分析1.html中的代码,它包含了多个知识点的综合运用:

var name = "windowName";
var func1 = function() {
  console.log('func1');
}
var a = {
  name: "Cherry",
  func1: function() {
    console.log(this.name);
  },
  func2: function() {
    setTimeout(function() {
      this.func1(); // 这里原本有问题
      return function() {
        console.log('hahaha');
      }
    }.call(a), 3000) // ⚠️ 注意:这里用了call
  }
}

这里有个坑:

setTimeout的第一个参数应该是函数,但.call(a)立即执行这个函数,并把返回值作为第一个参数传给setTimeout。这里返回的是undefined,相当于:

// 实际执行效果
setTimeout(undefined, 3000)

正确写法:

// 使用bind(不会立即执行)
setTimeout(function() {
  this.func1();
}.bind(a), 3000)

// 或者使用箭头函数
setTimeout(() => {
  this.func1(); // 这里的this继承自func2
}, 3000)

第六章:面试题精选

6.1 经典组合题

var name = 'window';

var obj = {
  name: 'obj',
  fn1: function() {
    console.log(this.name);
  },
  fn2: () => {
    console.log(this.name);
  },
  fn3: function() {
    return function() {
      console.log(this.name);
    }
  },
  fn4: function() {
    return () => {
      console.log(this.name);
    }
  }
}

obj.fn1();      // 'obj' - 对象方法调用
obj.fn2();      // 'window' - 箭头函数,this指向外层window
obj.fn3()();    // 'window' - 独立函数调用
obj.fn4()();    // 'obj' - 箭头函数,this继承自fn4的this

6.2 优先级问题

function foo() {
  console.log(this.name);
}

const obj1 = { name: 'obj1', foo };
const obj2 = { name: 'obj2' };

obj1.foo();                 // 'obj1'
obj1.foo.call(obj2);       // 'obj2' - call优先于隐式绑定
const bound = foo.bind(obj1);
bound.call(obj2);          // 'obj1' - bind绑定后,call无法改变

this绑定优先级:

  1. new绑定(最高)
  2. call/apply/bind显式绑定
  3. 对象方法调用(隐式绑定)
  4. 默认绑定(独立函数调用,最低)

总结

this的指向规律其实很简单,记住这几点:

  1. 函数被调用时才能确定this指向
  2. 普通函数:谁调用我,我指向谁
  3. 箭头函数:我在哪里定义,this就跟谁一样
  4. 可以通过bind永久绑定this,call/apply临时绑定this

理解了这些,JavaScript的this问题就迎刃而解了。


练习题

// 尝试分析下面的输出
const obj = {
  name: 'obj',
  say: function() {
    setTimeout(() => {
      console.log(this.name);
    }, 100);
  }
}

obj.say(); // 输出什么?

const say = obj.say;
say(); // 输出什么?

答案和解析欢迎在评论区讨论!


如果你觉得这篇文章对你有帮助,请点赞👍收藏⭐,让更多的小伙伴看到!我们下期再见!

谷歌浏览器取色器插件源码学习分享

在使用谷歌浏览器的时候,我们经常会使用到浏览器插件,作为技术肯定也要学习一下浏览器插件的开发咯,这篇文章给大家分享一个 我最近开发的一个浏览器插件-取色器,因为是学习阶段,所以功能做的还是比较简单的。

插件样子

image.png

使用技术:html css和js

image.png

插件部分代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>取色器</title>
  <style>
    body {
      width: 200px;
      padding: 15px;
      font-family: Arial, sans-serif;
      margin: 0;
    }
    h3 {
      margin: 0 0 15px 0;
      text-align: center;
      color: #333;
    }
    #startBtn {
      width: 100%;
      padding: 12px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: 16px;
      font-weight: bold;
      transition: transform 0.2s, box-shadow 0.2s;
    }
    #startBtn:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
    }
    #startBtn:active {
      transform: translateY(0);
    }
    .tip {
      margin-top: 15px;
      font-size: 12px;
      color: #666;
      text-align: center;
      line-height: 1.5;
    }
    .ad {
      margin-top: 20px;
      padding-top: 15px;
      border-top: 1px solid #e0e0e0;
      font-size: 11px;
      color: #999;
      text-align: center;
      line-height: 1.6;
    }
    .ad a {
      color: #667eea;
      text-decoration: none;
    }
    .ad a:hover {
      text-decoration: underline;
    }
  </style>
</head>
<body>
  <h3>🎨 取色器</h3>
  <button id="startBtn">开始取色</button>
  <div class="tip">
    点击按钮后,在网页上任意位置<br>
    左键点击即可获取颜色值
  </div>
  <div class="ad">
    万物OOP出品<br>
    <a href="https://www.wwwoop.com" target="_blank">www.wwwoop.com</a>
  </div>
  <script src="popup.js"></script>
</body>
</html>
// 获取开始取色按钮
const startBtn = document.getElementById('startBtn');

// 点击开始取色按钮
startBtn.addEventListener('click', async () => {
  // 向当前活动标签页发送消息,开始取色模式
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  
  if (tab && tab.id) {
    try {
      // 先尝试发送消息
      await chrome.tabs.sendMessage(tab.id, { action: 'startPickColor' });
      window.close();
    } catch (error) {
      // 如果消息发送失败,说明content script未加载,先注入
      console.log('Content script未加载,正在注入...');
      try {
        await chrome.scripting.executeScript({
          target: { tabId: tab.id },
          files: ['content.js']
        });
        // 注入CSS
        await chrome.scripting.insertCSS({
          target: { tabId: tab.id },
          files: ['content.css']
        });
        // 重新发送消息
        await chrome.tabs.sendMessage(tab.id, { action: 'startPickColor' });
        window.close();
      } catch (injectError) {
        console.error('注入脚本失败:', injectError);
        alert('无法在此页面使用取色器,请刷新页面后重试');
      }
    }
  }
});

从零搭建多 Agent 智能编排平台:NestJS + LangGraph 全栈实战

从零搭建多 Agent 智能编排平台:NestJS + LangGraph 全栈实战

本文分享一个开源项目 Nest-Agent——基于 NestJS + LangGraph 构建的多 Agent 编排平台,支持 Supervisor 自动路由、DAG 自定义工作流、RAG 知识库检索,遵循 AG-UI 标准协议实现 token 级流式输出。如果对你有帮助,欢迎 GitHub Star 支持 ⭐


为什么做这个项目?

当前 AI Agent 框架层出不穷,Python 生态的 LangChain、CrewAI 已经很成熟。但在 Node.js / TypeScript 生态,企业级的 Agent 编排后端方案相对匮乏。作为一个 NestJS 爱好者,我希望用最熟悉的技术栈打造一个:

  • 生产级的多 Agent 协同平台,不是 demo
  • 支持 Supervisor 自动路由DAG 自定义工作流 两种编排模式
  • 遵循 AG-UI 标准协议,前后端解耦,可对接 CopilotKit 等主流前端 SDK
  • 内置 RAG 知识库多 LLM 供应商多租户隔离,开箱即用

于是有了 Nest-Agent


技术栈一览

层次 技术
后端框架 NestJS 11
Agent 编排 LangChain + LangGraph
数据库 MySQL 8.0 (TypeORM)
缓存 Redis 7 (ioredis)
向量库 Milvus 2.3
认证 Passport JWT
前端 React 18 + shadcn/ui + Vite
包管理 pnpm workspace (monorepo)
部署 Docker Compose 一键启动

核心功能展示

1. AI 对话 — 流式输出 + 工具调用可视化

对话是整个平台的核心。输入一个问题,Supervisor 自动判断是否需要搜索、检索知识库还是直接回答,整个过程实时流式展示。

对话首页

流式输出

亮点:

  • Token 级别的流式输出,逐字显示 AI 回复
  • 实时展示 Agent 执行步骤(researcher → 搜索 → 生成回答)
  • 工具调用全程可视化——工具名、参数、返回结果一目了然
  • Markdown 富文本渲染(代码块、表格、列表、链接等)
  • 对话标题自动生成,不再是千篇一律的 "New Conversation"

对话详情

2. 工作流编排 — 可视化 DAG 工作流

除了 Supervisor 自动模式,你还可以自定义 DAG 工作流,精确控制 Agent 的执行流程。

工作流列表

创建工作流

支持的节点类型:

  • agent — 可调用工具的 AI Agent,支持自定义 system prompt
  • tool — 直接调用工具节点
  • condition — 条件分支,根据关键词路由到不同分支
  • start / end — 起止标记

3. RAG 知识库 — 语义检索

上传文档到知识库,Agent 对话时自动检索相关知识片段,实现基于私有数据的问答。

知识库列表

语义检索

RAG Pipeline:

文档 → 分块(1000/200) → BAAI/bge-m3 Embedding → Milvus 向量存储
查询 → Embedding → Milvus ANN 检索 → Top-K 结果

4. 认证系统 — 多租户隔离

登录注册

JWT 认证 + TenantGuard,所有数据按 tenantId 隔离,天然支持多团队使用。


架构设计

整体分层

┌──────────────────────────────────────────────────────────────┐
│                     React + shadcn/ui                         │
│                (SSE 客户端 + AG-UI 事件解析)                   │
└────────────────────────┬─────────────────────────────────────┘
                         │ HTTP POST + SSE 流
┌────────────────────────▼─────────────────────────────────────┐
│                     NestJS 应用层                              │
│  ┌──────┐ ┌──────┐ ┌───────┐ ┌──────┐                       │
│  │ Auth │ │ Chat │ │ Agent │ │ RAG  │  Controller 层          │
│  └──┬───┘ └──┬───┘ └───┬───┘ └──┬───┘                       │
│     │        │         │        │                             │
│  ┌──▼───┐ ┌──▼───┐ ┌───▼───┐ ┌──▼───┐                       │
│  │ Auth │ │ Chat │ │Agent  │ │ RAG  │  Service 层            │
│  └──────┘ └──────┘ └───┬───┘ └──────┘                       │
│                        │                                      │
│           ┌────────────┼────────────┐                        │
│     ┌─────▼─────┐ ┌────▼────┐ ┌────▼────┐                   │
│     │ Supervisor │ │   DAG   │ │  Tool   │  核心编排层        │
│     │  Factory   │ │ Engine  │ │Registry │                   │
│     └───────────┘ └─────────┘ └─────────┘                   │
│                                                               │
│     ┌─────────┐ ┌────────┐ ┌────────┐                       │
│     │   LLM   │ │ Milvus │ │ Redis  │  基础设施层            │
│     │ Service  │ │Service │ │Service │                       │
│     └─────────┘ └────────┘ └────────┘                       │
└──────────────────────────────────────────────────────────────┘
         │             │           │
    ┌────▼────┐  ┌─────▼────┐ ┌───▼──┐
    │OpenAI/  │  │ Milvus   │ │Redis │  存储层
    │Anthropic│  │ 2.3      │ │  7   │
    │/Qwen    │  └──────────┘ └──────┘
    └─────────┘

两种编排模式对比

Supervisor 模式(默认)

适合通用对话场景。LLM 充当"主管",根据用户意图动态路由到合适的 Agent:

用户: "搜索一下 NestJS 11 新功能"
  → Supervisor 判断: 需要搜索,路由到 researcher
  → researcher 调用 web_search 工具
  → researcher 根据搜索结果生成回答
  → Supervisor 判断: 任务完成,路由到 __end__

核心实现:Supervisor 使用 LLM + withStructuredOutput(zod schema) 做结构化输出,返回 { next: "researcher" | "responder" | "__end__" }

DAG 模式(自定义工作流)

适合复杂业务流程。用户预定义有向无环图,精确控制执行链路:

Start → 研究员Agent(调用搜索) → 条件判断 → 写手Agent(生成报告) → End
                                    ↘ 工具节点(直接检索) ↗

核心实现:将 JSON 格式的 nodes[] + edges[] 编译为 LangGraph 的 StateGraph,通过 Command({ goto, update }) 控制状态转移。


关键技术实现细节

1. AG-UI 流式事件协议

这是项目中最复杂也最有价值的部分。系统遵循 AG-UI 标准协议,通过 SSE 推送细粒度事件:

event: RUN_STARTED
data: {"type":"RUN_STARTED","threadId":"xxx","runId":"xxx"}

event: STEP_STARTED
data: {"type":"STEP_STARTED","stepName":"researcher"}

event: TOOL_CALL_START
data: {"type":"TOOL_CALL_START","toolCallId":"xxx","toolCallName":"web_search"}

event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"xxx","delta":"逐字输出..."}

event: RUN_FINISHED
data: {"type":"RUN_FINISHED","threadId":"xxx","runId":"xxx"}

三段式文本消息TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT(增量) → TEXT_MESSAGE_END

四段式工具调用TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULT

遵循标准协议意味着可以直接对接 CopilotKit 等前端 SDK,无需自定义解析。

2. LangGraph streamEvents → AG-UI 的精细转换

AgentService.processStreamEvents() 方法实现了 LangGraph 底层事件到 AG-UI 协议的映射,需要处理几个棘手的问题:

问题 1:Supervisor 路由节点的输出不应暴露给用户

Supervisor 的结构化输出({ next: "researcher" })是内部路由决策,不应该推送给前端。通过节点名过滤解决。

问题 2:嵌套子 Agent 的"思考"文本

createReactAgent 内部的 LLM 在调用工具前会输出一些"思考"文本(通常是复述 prompt),这些不应显示给用户。解决方案是通过 langgraph_checkpoint_ns 区分嵌套层级:

const isNestedAgent = parentNode && langgraphNode !== parentNode;
if (isNestedAgent && !nodeToolsDone.get(nodeKey)) {
  // 暂存到 pendingTextPerNode,工具完成后才释放
}

问题 3:XML 工具调用格式兼容

某些模型(如 qwen)会以 <tool_call> XML 标签输出工具调用。代码使用正则检测并过滤,避免 XML 标签泄露到前端。

3. 智能记忆管理

对话记忆采用 滑动窗口 + LLM 自动摘要 的混合策略:

消息数 ≤ 10        → 全量返回,无压缩
10 < 消息数 ≤ 20   → 滑动窗口,取最近 10 条
消息数 > 20        → LLM 对早期消息生成摘要,摘要 + 最近 10 条

摘要存储在 Conversation.summary 字段,支持增量摘要(新对话内容追加到现有摘要上)。会话消息缓存在 Redis,TTL 3600 秒,减少数据库查询。

4. 多 LLM 供应商抽象

统一的模型工厂,切换供应商只需一个参数:

// 用户请求时指定
{ llmOptions: { provider: "openai", model: "gpt-4o" } }
{ llmOptions: { provider: "dashscope", model: "qwen-max" } }
{ llmOptions: { provider: "anthropic", model: "claude-sonnet-4-20250514" } }

DashScope(通义千问)通过 OpenAI 兼容协议接入,自定义 baseURL 即可。同样的方式可以接入 SiliconFlow、Deepseek、vLLM 等任何兼容 OpenAI API 的服务。

5. 动态工具 + 多租户隔离

工具系统采用注册表模式。web_search 是全局静态工具,而 rag_retrieval 因为需要 tenantId 上下文,采用工厂函数动态创建

// 每次请求动态创建,确保 tenantId 隔离
const ragTool = createRagRetrievalTool(ragService, tenantId);

这样不同租户只能检索到自己的知识库数据。


项目结构

nest-agent/
├── src/                       # 后端(NestJS)
│   ├── auth/                  # JWT 认证 + 多租户守卫
│   ├── chat/                  # 对话管理 + SSE 流式接口 + 记忆策略
│   ├── agent/                 # 核心:Supervisor 路由 + DAG 引擎
│   │   ├── agent.service.ts   # 编排入口 + AG-UI 事件转换
│   │   ├── supervisor.factory.ts  # Supervisor 有向图构建
│   │   └── dag-engine.ts      # DAG 编译执行引擎
│   ├── rag/                   # RAG 知识库(Milvus 向量检索)
│   ├── llm/                   # 多 LLM 供应商抽象
│   ├── tools/                 # 工具注册中心
│   ├── redis/                 # Redis 缓存
│   ├── entities/              # TypeORM 实体
│   └── common/                # AG-UI 协议定义、配置、异常过滤器
├── web/                       # 前端(React + shadcn/ui + Vite)
│   └── src/
│       ├── pages/             # 对话、工作流、知识库、登录
│       ├── components/        # 布局 + shadcn/ui 组件
│       └── lib/               # API 封装、认证上下文
├── Dockerfile                 # 多阶段构建
├── docker-compose.yml         # MySQL + Redis + Milvus 一键启动
└── pnpm-workspace.yaml        # monorepo 配置

快速启动

Docker 一键部署

git clone https://github.com/peng-yin/nest-agent.git
cd nest-agent
cp .env.example .env
# 编辑 .env,填入 OPENAI_API_KEY 等配置
docker-compose up -d
# 访问 http://localhost:3000

本地开发

# 1. 启动基础设施
docker-compose up -d mysql redis etcd minio milvus-standalone

# 2. 安装依赖
pnpm install

# 3. 启动后端(热重载)
pnpm dev            # http://localhost:3000

# 4. 启动前端(另开终端)
pnpm dev:web        # http://localhost:5173

环境变量

变量 必填 说明
OPENAI_API_KEY OpenAI API Key
OPENAI_BASE_URL 自定义 API 地址(兼容 SiliconFlow/Deepseek)
TAVILY_API_KEY 网页搜索功能需要
JWT_SECRET 生产必填 JWT 签名密钥

踩过的坑

分享几个开发过程中遇到的典型问题:

1. createReactAgent 子图的"思考"文本泄露

使用 LangGraph 的 createReactAgent 时,内部 LLM 在决定调用工具前会输出一段"思考"文本。这些文本通过 streamEvents 被捕获并发送到前端,用户看到的是一堆 prompt 模板文字而非实际回答。

解决方案:通过 langgraph_checkpoint_ns 中的 langgraphNodeparentNode 区分顶层节点和子图内部调用。子图内部 LLM 的 langgraphNode"agent",而 parentNode"researcher",两者不同;普通顶层节点两者相同。利用 langgraphNode !== parentNode 识别嵌套调用,将工具调用前的文本暂存丢弃。

2. StateGraph 节点的 checkpointNs 理解偏差

最初以为只有 createReactAgent 构建的子图才有 checkpointNs,但实际上 StateGraph 中所有节点都有。导致 responder(直接回答)的正常文本也被错误暂存。debug 日志大法好。

3. 阿里云 DashScope 的 XML 工具调用

通过 OpenAI 兼容 API 调用 qwen 模型时,部分场景下工具调用不走标准的 tool_calls 字段,而是在文本中输出 <tool_call> XML 标签。需要正则检测并过滤,否则前端会显示一堆 XML。


后续计划

  • 支持更多工具(代码执行、文件上传、图片生成等)
  • 工作流可视化编辑器(拖拽式 DAG 编辑)
  • Agent 执行过程的可视化 Trace
  • 支持更多向量数据库(Pinecone、Qdrant)
  • 支持文件上传(PDF、Word 等文档直接入库)

写在最后

这个项目从架构设计到编码实现,全部由一个人完成。涵盖了 Agent 编排、流式通信、RAG 检索、多租户隔离等多个技术领域,希望能为 Node.js/TypeScript 生态的 AI Agent 开发提供一个可参考的实践案例。

代码完全开源,如果这个项目对你有帮助,或者你对 NestJS + AI Agent 感兴趣,欢迎:

  • Star 这个项目:GitHub - nest-agent
  • 🐛 提 Issue 或 PR,一起完善
  • 💬 留言交流,分享你的想法

感谢阅读!

学成在线 案例练习

大家好,我是糖糖~最近我跟着 b 站 pink 老师做了学成在线的案例练习,收获满满,今天就来和大家分享一下我的学习过程与心得,一起开启技术学习新旅程!

一.案例准备工作

graph TD
A[创建study目录文件夹] --> B[在vscode打开目录文件夹]
B --> C[study目录内新建images文件夹]
C --> D[新建首页文件index.html]
D --> E[新建style.css样式文件采用外链样式]
E --> F[样式表写入清除内外边距的样式来检验样式表是否引入成功]

index.html:!符号+Tab键生成模板,link+Taba键添加外链样式;

body里随机写入内容用于测试引入是否成功

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>学成在线首页</title>
    <link rel="stylesheet" href="style.css">
</head>

<body>
    123
</body>
</html>

image.png 样式文件去除内外边距,代码如下

* {
    margin: 0;
    padding: 0;
}

image.png 通过浏览器预览网页再F12按键,若看到以下清除内外边距的内容,说明引入成功

image.png

二.确定版心(页面宽度)

每个版心都要水平居中,可以定义版心公共类;

样式文件添加:

.w{
    width: 1200px;
    margin: auto;
}

image.png

三.分块实现

接下来一行一行的实现,先写第一行的每一列,做完以后以此类推,在做第二行、第三行;

3.1头部制作

3.1.1第一行盒子

1.量头部内容高度和上下外边框高度;
2.在index.html文件的body里加入头部盒子代码,并引入之前的公共类.w;

  <div class="header w">

  </div>

3.在style.css文件加入高度和外边距属性;

.header{
    height: 42px;
    background-color: pink;
    /* 注意此地方会层叠.w 里面的margin,故要写左右的auto */
    margin: 30px auto;
}

3.1.2第一行第一列(logo部分)

index.html的logo部分:

         <!-- logo部分 -->
        <div class="logo">
            <img src="images/logo.png" alt="">
        </div>

image.png style.css的logo样式部分:

.logo {
    width: 198px;
    height: 42px;
    background-color: purple;
}

image.png

3.1.3第一行第二列(导航栏部分)

实际开发中,我们不会直接用链接a而是用li包含链接(li+a)的做法。
原因:
1.li+a语义更清晰,一看这就是有条理的列表型内容。
2.如果直接用a,搜索引擎容易辨别为有堆砌关键词嫌疑从而影响网站排名

导航栏代码:
<ul>
    <li><a href="#">首页</a></li>
    <li><a href="#">课程</a></li>
    <li><a href="#">职业规划</a></li>
</ul>
样式代码:

1.去掉li的小圆点

li{
   list-style: none;
}

2.注意当导航栏加浮动时,logo也要加上浮动

.nav{
    float: left;
    margin-left: 60px;
}

3.链接去掉下划线

a {
    text-decoration: none;
}

4.让导航栏里的内容横向排列,因为li是块状元素需要一行显示,加浮动

.nav ul li {
    float: left;
}

5.链接样式:转为行内块元素才能调整高度,设置内边距、行高、字符大小、颜色

.nav ul li a {
    display: block;
    height: 42px;
    padding: 0 10px;
    line-height: 42px;
    font-size: 18px;
    color: #050505;

}

注意:

这个nav导航栏可以不加宽度,将来可以加其余文字;
因为导航栏里面文字不够多,所以最好给链接a左右内边距padding撑开盒子,而不是指定宽度。

6.鼠标经过链接的样式代码

.nav ul li a {
    display: block;
    height: 42px;
    padding: 0 10px;
    line-height: 42px;
    font-size: 18px;
    color: #050505;

}
效果图

image.png

3.1.4第一行第三列(search搜索模块)

1.结构分析

一个search大盒子里面包括两个表单=input文本框+button按钮

   <!-- 搜索模块 -->
        <div class="search">
            <input type="text" value="输入关键词">
            <button></button>
        </div>

2.样式代码实现

搜索框部分

style.css

/* search搜索模块 */
.search {
    float: left;
    width: 412px;
    height: 42px;
    background-color: skyblue;
    margin-left: 70px;
}

.search input {
    width: 345px;
    height: 40px;
    border: 1px solid #00a4ff;
    border-right: 0;
    color: #bfbfbf;
    font-size: 14px;
    padding-left: 15px;
}

注意: 加上左内边距后,盒子会被撑大,在宽度减去左内边距360-15=345

按钮部分
.search button {
    float: left;
    width: 50px;
    height: 42px;
    background: url(images/btn.png);
    /* 按钮button默认有个边框需要我们手动去掉 */
    border: 0;
}

注意: 去掉按钮默认边框; 使按钮在搜索框右边则加上浮动,此时搜索框也要加上浮动。

效果图

image.png

3.1.5第一行第四列(用户user模块制作)

1.用一个盒子装用户头像和用户名

<!-- 用户模块 -->
        <div class="user">
            <img src="images/user.png" alt="">
            qq-lilei
        </div>

2.样式:右浮动、行高、右外边距、字体大小、颜色

/* 用户模块 */
.user {
    float: right;
    line-height: 42px;
    margin-right: 30px;
    font-style: 14px;
    color: #666;
}

##背景色 1.设置整个页面背景色

body {
    background-color: #f3f5f7;
}

2.去掉头部辅助的背景色

头部模块效果图

image.png

3.2 banner制作

思路

大盒子banner里包含版心.w,版心里有左右两个子盒子。
左边盒子用ul>li>a span,大于符号用>
右边盒子用一个标题h4+ul>li*3+一个a链接
其中li里面有h4和p

代码实现

  <!-- banner部分start -->
    <div class="banner">
        <!-- 版心 左子盒子-->
        <div class="w">
            <div class="subnav">
                <ul>
                    <li><a href="#">前端开发<span>&gt;</span></a></li>
                    <li><a href="#">后端开发<span>&gt;</span></a></li>
                    <li><a href="#">移动开发<span>&gt;</span></a></li>
                    <li><a href="#">人工智能<span>&gt;</span></a></li>
                    <li><a href="#">商业预测<span>&gt;</span></a></li>
                    <li><a href="#">云计算&大数据<span>&gt;</span></a></li>
                    <li><a href="#">运维&从测试<span>&gt;</span></a></li>
                    <li><a href="#">UI设计<span>&gt;</span></a></li>
                    <li><a href="#">产品<span>&gt;</span></a></li>
                </ul>
            </div>
            
            <!--我的课程表  右子盒子 -->
            <div class="course">
                <h2>我的课程表</h2>
                <div class="bd">
                    <ul>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                        <li>
                            <h4>继续学习 程序语言设计</h4>
                            <p>正在学习-使用对象</p>
                        </li>
                    </ul>
                    <a href="#" class="more">全部课程</a>
                </div>
            </div>
        </div>
    </div>
    <!-- banner部分end -->

样式代码

/* banner区域 */
.banner {
    height: 421px;
    background-color: #1c036c;
}

.banner .w {
    height: 421px;
    background: url(images/banner2.png) no-repeat top center;
}

.subnav {
    float: left;
    width: 190px;
    height: 421px;
    background-color: rgba(0, 0, 0, 0.3);
}

.subnav ul li {
    height: 45px;
    line-height: 45px;
    padding: 0px 20px;


}

.subnav ul li a {
    font-style: 14px;
    color: #fff;

}

.subnav ul li a span {
    float: right;
    padding-right: 20px;
}

.subnav ul li a:hover {
    color: #00a4ff;
}

.course {
    float: right;
    width: 230px;
    height: 300px;
    background-color: #fff;
    /* 浮动的盒子不会有外边距合并的问题 */
    margin-top: 50px;
}

.course h2 {
    height: 48px;
    background-color: #9bceea;
    text-align: center;
    line-height: 48px;
    font-size: 18px;
    color: #fff;
}

.bd{
    padding: 0 20px;
}

.bd ul li{
    padding: 15px 0;
    border-bottom: 1px solid #ccc;
}
.bd ul li h4{
    font-size: 16px;
    color: #4e4e4e;
}

.bd ul li p {
    font-size: 12px;
    color: #a5a5a5;
}
.bd .more{
    display:block;
    height: 38px;
    border: 1px solid #00a4ff;
    text-align: center;
    line-height: 38px;
    color:#00a4ff;
    font: size 16px;
    font-weight: 700;
}

banner模块效果图

image.png

3.3精品推荐小模块

思路

大盒子水平居中goods精品,注意此处有个盒子阴影。
大盒子里面有三个小盒子,一号盒子是标题h3左侧浮动,二号盒子里面放链接左侧浮动,goods-item距离可以控制连接的左右边距(注意行内元素只给左右内外边距),三号盒子右浮动mod修改

代码实现

  <!--3. 精品推荐模块开始 -->
    <div class="goods w">
        <h3>精品推荐</h3>
         
            <ul>
            <li><a href="#">jQuery</a></li>
            <li><a href="#">Spark</a></li>
            <li><a href="#">MySQL</a></li>
            <li><a href="#">JavaWeb</a></li>
            <li><a href="#">MyAQL</a></li>
            <li><a href="#">JavaWeb</a></li>
        </ul>

        <a href="#"class="mod">修改兴趣</a>

    </div>
    <!--3. 精品推荐模块结束-->

样式代码

/* 精品推荐模块 */
.goods{
height: 60px;
background-color: #fff;
margin-top: 10px;
box-shadow: 0 2px 3px 3px rgba(0, 0, 0, 0.1);
/* 行高会继承 */
line-height: 60px;
}

.goods h3{
    float: left;
    margin-left: 30px;
    font-size: 16px;
    color: #00a4ff;
}

.goods ul{
    float: left;
    margin-left: 30px;
}

.goods ul li{
float: left;
   
}

.goods ul li a{

        padding: 0 30px;
        font-size: 16px;
        color: #050505;
        border-left: 1px solid #ccc;
}

.mod{
    float:right;
    margin-right:30px ;
    font-size: 14px;
    color: #00a4ff;
}

精品推荐小模块效果图

image.png

3.4精品推荐大模块

思路

1号盒子为最大盒子,box版心水平居中;
2号盒子为上面部分,box-hd--里面左侧标题h3左浮动,右侧链接a右浮动;
3号盒子为底下部分,box-bd--里面是无序列表,有10小li组成;
小li外边距的问题,这里有个小技巧:给box-hd宽度为1215就可以一行装开5个li
另外,Ctrl+g 输入1,到第一行,给body加一个高度,便于滑动,后面再删掉

具体注意事项:

把li的父亲ul修改的足够宽,一行能装开5个盒子就不会换行了 image.png

设置好一个盒子以后,再删掉其他li,复制粘贴第一个li,再修改内容和图片

image.png

代码实现

结构代码
<!-- 4.box核心内容其余开始 -->
     <div class="box w">

        <div class="box-hd">
            <h3>精品推荐</h3>
            <a href="#">查看全部</a>
        </div>

        <div class="box-bd">
            <ul>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                 <div class="info"><span>高级</span>  ·  1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
                <li>
                    <img src="images/pic.png" alt="">
                    <h4>Think PHP 5.0 博客系统实战项目演练</h4>
                    <div class="info"><span>高级</span> · 1125人在学</div>
                </li>
            </ul>
        </div>

     </div>
    <!-- 4.box核心内容其余结束-->
   
样式代码
/* 精品推荐大模块 */
/* box-hd部分 */
.box{
    margin-top: 30px;
}

.box-hd{
    height: 45px;
}

.box-hd h3{
float: left;
font-size: 20px;
color:#494949
}

.box-hd a{
    float:right;
    font-size: 12px;
    color:#a5a5a5;
    margin-top: 10px;
    margin-right: 30px;
}

/* box-bd */
.box-bd a{
    float: right;
    font-size:12px;
    color: #a5a5a5;
    margin-top: 10px;
    margin-right: 30px;
}
/* 把li的父亲ul修改的足够宽,一行能装开5个盒子就不会换行了 */
.box-bd{
    width: 1215px;
}

.box-bd ul li{
    float:left;
    width: 228px;
    height: 270px;
    background-color: #fff;
    margin-right: 15px;
    margin-bottom: 15px;
}

.box-bd ul li img{
    width: 100%;
}

.box-bd ul li h4{
margin: 20px 20px 20px 25px;
font-size: 14px;
color: #050505;
font-weight: 400;
}

.box-bd .info{
    margin: 0 20px 0 25px;
    font-size: 12px;
    color:#999
}

.box-bd .info span{
    color:#ff7c2d;
}

效果图(未修改成不同内容版)

image.png

3.5底部模块

思路

1号盒子是通栏大盒子,底部footer给高度,底色是白色
2号盒子版心水平居中
3号盒子版权copyright左对齐
4号盒子链接links右对齐

所遇问题

为便于观察,先用粉色背景,由于浮动不占空间,粉色大盒子不在预想位置,如下图,此时需在li的父亲ul去浮动 image.png 去浮动代码(清除浮动之双伪元素清除)

样式代码添加

.clearfix:before,
 .clearfix:after {
     content: "";
     display: table;
 }

 .clearfix:after {
     clear: both;
 }

 .clearfix {
     zoom: 1;
 } 

在结构代码的ul加上clearfix的类名

  <ul class="clearfix ">

去浮动以后,效果图如下 image.png

用外上边距会出现塌陷,如图所示

image.png 要用内边距

.footer .w{
    /* 不要用外边距 */
    /* margin-top: 35px; */
    padding-top: 35px;
}

image.png

代码实现

结构代码
 <!-- 5.footer底部模块开始 -->
     <div class="footer">
        <div class="w">
            <div class="copyright">
                <img src="images/logo.png" alt="">
                <p>学成在线致力于普及中国最好的教育它与中国一流大学和机构合作提供在线课程。<br>
               © 2017年XTCG Inc.保留所有权力。-沪ICP备15025210号</p>

               <a href="#" class="app">下载APP</a>

            </div>
            <div class="links">
                <dl>
                    <dt>关于学成网</dt>
                    <dd><a href="#">关于</a></dd>
                    <dd><a href="#">管理团队</a></dd>
                    <dd><a href="#">工作机会</a></dd>
                    <dd><a href="#">客户服务</a></dd>
                    <dd><a href="#">帮助</a></dd>
               
                </dl>
                <dl>
                    <dt>新手指南</dt>
                    <dd><a href="#">如何注册</a></dd>
                    <dd><a href="#">如何选课</a></dd>
                    <dd><a href="#">如何拿到毕业证</a></dd>
                    <dd><a href="#">学分是什么</a></dd>
                    <dd><a href="#">考试未通过怎么办</a></dd>
                
                </dl>
                <dl>
                    <dt>合作伙伴</dt>
                    <dd><a href="#">合作机构</a></dd>
                    <dd><a href="#">合作导师</a></dd>
                  
                
                </dl>
            </div>

        </div>
     </div>
    <!-- 5.footer底部模块结束 -->
样式代码
/* .footer模块 */
.footer{
height: 415px;
background-color: #fff;
}

.footer .w{
    /* 不要用外边距 */
    /* margin-top: 35px; */
    padding-top: 35px;
}

.copyright{
    float: left;
}

.copyright p{
    font-size: 12px;
    color:#666;
    margin: 20px 0 ;
}
.copyright .app{
    display:block;
    width: 118px;
    height: 33px;
    border: 1px solid #00a4ff;
    text-align: center;
    line-height: 33px;
    color:#00a4ff;
    font-size: 16px;
}

.links{
    float:right;
}

.links dl{
    float: left;
    margin-left: 100px;
}

.links dl dt{
    font-size: 16px;
    color:#333;
}

.links dl dd a {
    font-size: 12px;
    color: #333;
}

底部效果图

image.png

4.总结

为方便大家查看相关代码,我已将本次学成在线案例练习的代码上传至 GitHub 仓库,链接:[github.com/TANG1110/fr…] 。
从摸索代码到看到模块效果,每一步都充满挑战与惊喜。小伙伴们,学习之路虽有坎坷,但坚持就有收获 !希望大家多多关注我,并点赞收藏这篇文章,给予我更多支持与鼓励,我们一起在技术海洋里并肩前行,共同成长~

前端/iOS开发者必备工具软件合集

近期新购入了一款 Mac,趁此机会重装了一遍所有软件,记录一下程序员必备软件合集,方便下次换机或新同事参考。


一、环境与包管理(安装顺序建议)

1. Homebrew

Mac 的包管理器,建议最先安装,后续很多软件可通过它一键安装。

# 安装(官方一键脚本)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# 安装后按提示把 brew 加入 PATH(Apple Silicon 常见路径)
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile && eval "$(/opt/homebrew/bin/brew shellenv)"

常用技巧:

  • brew install <包名>:安装软件
  • brew upgrade:升级所有已安装包
  • brew search <关键词>:搜索可用包
  • brew list:查看已安装列表
  • brew cleanup:清理旧版本缓存
  • brew cask install <应用>:安装图形界面应用(如 Chrome、Cursor)

2. nvm(Node 版本管理)

多项目可能依赖不同 Node 版本,用 nvm 切换很方便。

# 使用 Homebrew 安装
brew install nvm
# 在 ~/.zshrc 中加入(如用 Oh My Zsh 则编辑该文件)
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && \. "/opt/homebrew/opt/nvm/nvm.sh"
[ -s "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm" ] && \. "/opt/homebrew/opt/nvm/etc/bash_completion.d/nvm"
# 然后执行 source ~/.zshrc

常用技巧:

  • nvm install 20:安装 Node 20 最新版

  • nvm use 18:当前终端切换到 Node 18

  • nvm alias default 20:默认使用 Node 20

  • nvm ls:查看已安装版本

  • 项目根目录放 .nvmrc 写版本号(如 20),在目录下执行 nvm use 即可自动切换

  • 技巧2
    在.nvmrc 中写入当前支持的node版本号node -v > .nvmrc

autoload -U add-zsh-hook
load-nvmrc() {
  local nvmrc_path
  nvmrc_path="$(nvm_find_nvmrc)"

  if [ -n "$nvmrc_path" ]; then
    local nvmrc_node_version
    nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")

    if [ "$nvmrc_node_version" = "N/A" ]; then
      nvm install
    elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
      nvm use
    fi
  elif [ "$(nvm version)" != "$(nvm version default)" ]; then
    # 离开有 .nvmrc 的目录,切回默认版本
    echo "Reverting to nvm default version"
    nvm use default
  fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc

即可实现切换到当前文件自动切换到对应的node版本


3. pnpm

比 npm 更快、省磁盘,适合 Monorepo 和前端项目。

# 安装(建议在配置好 nvm 后执行,全局安装到当前 Node)
npm install -g pnpm
# 或使用 Homebrew
brew install pnpm

常用技巧:

  • pnpm install:安装依赖(会严格按 workspace 和 lockfile)
  • pnpm add <包名> / pnpm add -D <包名>:添加依赖 / 开发依赖
  • pnpm run <script>pnpm <script>:运行 package.json 里脚本
  • pnpm store path:查看全局 store 目录;pnpm store prune:清理未引用包
  • 在项目里用 pnpm 前可先 corepack enablecorepack prepare pnpm@latest --activate,便于团队统一 pnpm 版本

4. rvm + CocoaPods

iOS/macOS 开发常用 Ruby 环境管理 + 依赖管理。

# 安装 rvm
curl -sSL https://get.rvm.io | bash -s stable
source ~/.rvm/scripts/rvm

# 安装 Ruby(建议 3.x,与 CocoaPods 兼容好)
rvm install 3.2.0
rvm use 3.2.0 --default

# 安装 CocoaPods
sudo gem install cocoapods -n /usr/local/bin

常用技巧:

  • rvm
    • rvm list:已安装 Ruby 列表
    • rvm use 3.2.0:切换版本
    • 项目目录放 .ruby-version 可自动切换
  • CocoaPods
    • pod init:在 Xcode 工程目录下初始化 Podfile
    • pod install:安装/更新依赖(之后用 .xcworkspace 打开工程)
    • pod update <库名>:更新指定库
    • pod repo update:更新 CocoaPods 官方 spec 仓库(卡住时常用)
    • 国内镜像若慢,可在 Podfile 顶部指定 source,或使用 CDN 源

二、必备软件列表

类型 软件 说明
浏览器 Chrome 开发、调试、日常
终端 iTerm2 + Oh My Zsh 更好用的终端与 shell 配置
抓包/调试 Proxyman macOS 抓包、证书、代理
开发 Xcode iOS/macOS 开发必备
编辑器 Cursor AI 辅助编码
系统监控 iStat Menus 菜单栏 CPU/内存/网速等
数据库 TablePlus 多数据库客户端
截图 iShot 截图、标注、录屏
远程 Termius SSH 等远程连接
JSON OK JSON JSON 查看工具

三、Cursor 已安装插件

当前 Cursor 中已安装的扩展(便于换机或重装时恢复):

扩展 ID 说明
bradlc.vscode-tailwindcss Tailwind CSS IntelliSense(类名补全、预览)
christian-kohler.npm-intellisense npm 包名/版本补全
christian-kohler.path-intellisense 路径补全(import、src 等)
dbaeumer.vscode-eslint ESLint 代码检查
dsznajder.es7-react-js-snippets ES7+ React/Redux 代码片段
eamodio.gitlens Git 增强(行历史、blame、对比等)
esbenp.prettier-vscode Prettier 格式化
formulahendry.auto-rename-tag 自动重命名配对的 HTML/JSX 标签
ms-ceintl.vscode-language-pack-zh-hans 中文(简体)语言包
pkief.material-icon-theme Material 风格文件/文件夹图标
ritwickdey.liveserver 本地 Live Server 前端预览
steoates.autoimport 自动补全并插入 import
stylelint.vscode-stylelint CSS/SCSS/Less 的 Stylelint 检查
tomi.xasnippets Xcode 风格代码片段(iOS 开发)
usernamehw.errorlens 行内显示错误/警告(Error Lens)
wallabyjs.console-ninja Console Ninja(控制台日志增强)
yzhang.markdown-all-in-one Markdown 编辑与预览增强

一键安装(在 Cursor 终端执行):

cursor --install-extension bradlc.vscode-tailwindcss
cursor --install-extension christian-kohler.npm-intellisense
cursor --install-extension christian-kohler.path-intellisense
cursor --install-extension dbaeumer.vscode-eslint
cursor --install-extension dsznajder.es7-react-js-snippets
cursor --install-extension eamodio.gitlens
cursor --install-extension esbenp.prettier-vscode
cursor --install-extension formulahendry.auto-rename-tag
cursor --install-extension ms-ceintl.vscode-language-pack-zh-hans
cursor --install-extension pkief.material-icon-theme
cursor --install-extension ritwickdey.liveserver
cursor --install-extension steoates.autoimport
cursor --install-extension stylelint.vscode-stylelint
cursor --install-extension tomi.xasnippets
cursor --install-extension usernamehw.errorlens
cursor --install-extension wallabyjs.console-ninja
cursor --install-extension yzhang.markdown-all-in-one

四、后续可补充

  • 各软件的偏好设置或快捷键
  • 常用 Xcode 配置(证书、模拟器、Build Settings 等)
  • 团队统一的 .nvmrc.ruby-version、编辑器推荐插件配置(如放入仓库 .vscode/extensions.json
❌