阅读视图

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

DOM树与节点操作:用JS给网页“动手术”

你写的HTML页面,在浏览器眼里其实是一棵树。今天我们就来当一回“外科医生”,用JS给这棵树做手术——增、删、改、查,想怎么动就怎么动。看完这篇,你就能理解为什么说“JS能控制网页的一切”。

前言

你有没有想过,当你用document.getElementById拿到一个元素,然后改它的文字、换它的颜色时,背后发生了什么?

其实,浏览器把HTML解析成了一棵“树”,每个标签、属性、文本都是树上的一个“节点”。JS能做的,就是在这棵树上爬上爬下,找到某个节点,然后对它做各种操作——换个果子、摘掉枯枝、甚至嫁接新枝。

今天我们就来解剖这棵DOM树,学会用JS给网页“做手术”。

一、DOM树:网页的“族谱”

DOM(Document Object Model)把HTML文档表示成一棵树。比如这段HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>我的网页</title>
  </head>
  <body>
    <div class="container">
      <h1>标题</h1>
      <p>一段文字</p>
    </div>
  </body>
</html>

在浏览器眼里,它长这样:

html
├── head
│   └── title
│       └── "我的网页"
└── body
    └── div.container
        ├── h1
        │   └── "标题"
        └── p
            └── "一段文字"

每个方框都是一个节点。节点之间是父子、兄弟关系。这棵树的根节点是document

节点有不同的类型,最常见的是:

  • 元素节点:比如<div><p>,类型是1
  • 文本节点:比如“标题”这两个字,类型是3
  • 属性节点:比如class="container",类型是2(但很少单独操作)

二、获取节点:找到你要动刀的位置

做手术第一步,得找到病灶。JS提供了好几种“找节点”的方法:

1. 单个元素

// 根据ID(最常用)
const header = document.getElementById('header');

// 根据CSS选择器(推荐,灵活)
const container = document.querySelector('.container');
const title = document.querySelector('#title');

// 根据类名(返回集合)
const items = document.getElementsByClassName('item'); // HTMLCollection,实时更新

2. 多个元素

// 获取所有匹配的元素
const allDivs = document.querySelectorAll('div'); // NodeList,静态快照

// 根据标签名
const paras = document.getElementsByTagName('p'); // HTMLCollection

3. 在节点之间“爬树”

拿到一个节点后,你可以在它周围爬来爬去:

const container = document.querySelector('.container');

// 往上爬
const parent = container.parentNode;

// 往下爬
const firstChild = container.firstChild; // 可能是文本节点(换行)
const firstElementChild = container.firstElementChild; // 只算元素

// 找兄弟
const prev = container.previousSibling; // 可能是文本节点
const prevElement = container.previousElementSibling;
const next = container.nextElementSibling;

坑点firstChildnextSibling这些会返回文本节点(包括换行和空格),所以大部分时候用firstElementChildnextElementSibling更安全。

三、修改节点:动手术的核心操作

找到目标后,就可以下手了。

1. 修改内容和属性

// 改文本内容
element.textContent = '新文本'; // 纯文本,安全
element.innerHTML = '<strong>新文本</strong>'; // 解析HTML,有XSS风险

// 改属性
element.id = 'newId';
element.className = 'newClass'; // 覆盖所有类
element.classList.add('active'); // 推荐,增删类
element.classList.remove('hidden');
element.classList.toggle('open');

// 改样式(内联样式)
element.style.color = 'red';
element.style.backgroundColor = '#f0f0f0'; // 驼峰命名

2. 创建新节点

// 创建元素
const newDiv = document.createElement('div');
newDiv.textContent = '我是新来的';

// 创建文本节点(很少单独用)
const textNode = document.createTextNode('一段文字');

3. 插入节点

// 追加到最后
parent.appendChild(newDiv);

// 插入到某个子节点之前
parent.insertBefore(newDiv, referenceNode);

// 现代插入方法(更灵活)
referenceNode.before(newDiv); // 插到前面
referenceNode.after(newDiv);  // 插到后面
parent.prepend(newDiv);       // 插到父元素开头
parent.append(newDiv);        // 插到父元素末尾(类似appendChild)

4. 删除节点

// 删除自己
element.remove();

// 通过父节点删除
parent.removeChild(child);

四、实战:动态添加待办事项

来做个简单待办列表,把上面的操作串起来:

<div id="todo-app">
  <input type="text" id="todo-input" placeholder="输入待办事项">
  <button id="add-btn">添加</button>
  <ul id="todo-list"></ul>
</div>
const input = document.getElementById('todo-input');
const addBtn = document.getElementById('add-btn');
const list = document.getElementById('todo-list');

function addTodo() {
  const text = input.value.trim();
  if (text === '') return;
  
  // 创建li元素
  const li = document.createElement('li');
  li.textContent = text;
  
  // 创建删除按钮
  const delBtn = document.createElement('button');
  delBtn.textContent = '删除';
  delBtn.onclick = function() {
    li.remove(); // 删除这一项
  };
  
  li.appendChild(delBtn);
  list.appendChild(li);
  
  input.value = ''; // 清空输入框
}

addBtn.addEventListener('click', addTodo);
// 按回车也添加
input.addEventListener('keypress', function(e) {
  if (e.key === 'Enter') addTodo();
});

就这几行代码,一个动态待办列表就有了。你看,增删改查全用上了。

五、节点集合:HTMLCollection vs NodeList

当你用getElementsByClassName时,拿到的是HTMLCollection;用querySelectorAll拿到的是NodeList。它们有啥区别?

  • HTMLCollection:实时的。DOM变了,它也跟着变。而且它只有元素节点,没有文本节点。
  • NodeList:大部分是静态快照(querySelectorAll返回的就是静态的)。但childNodes返回的NodeList是实时的。
const live = document.getElementsByClassName('item'); // 实时
const static = document.querySelectorAll('.item'); // 静态

// 如果你删除了一个.item元素,live会立刻变少,static还是原来的

遍历时,HTMLCollection没有forEach方法(但可以Array.from()转成数组),NodeList有forEach

六、性能小贴士:别频繁动DOM

DOM操作是“重活”,频繁操作会影响性能。记住几个原则:

  1. 批量操作:用document.createDocumentFragment()创建虚拟片段,一次性插入。
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = i;
  fragment.appendChild(li);
}
list.appendChild(fragment); // 只触发一次重排
  1. 减少重排:修改样式时,尽量用classList批量改,而不是一个个改style属性。

  2. 离屏操作:先把元素从DOM树上摘下来,改完再放回去。

七、总结:DOM就是你的“手术台”

  • DOM是HTML解析成的树,每个标签、文本都是节点。
  • document.querySelector等方法找到节点。
  • textContentinnerHTML改内容,用classList改样式。
  • createElement造新节点,用appendinsertBefore插入,用remove删除。
  • 注意HTMLCollection和NodeList的区别,实时和静态要分清。
  • 批量操作、减少重排,让页面更流畅。

掌握了这些,你就能用JS随心所欲地操控页面。明天我们将继续深入,聊聊事件流与事件委托——当用户点击按钮时,浏览器里到底发生了什么。

如果你觉得今天的“手术”课够实用,点个赞让更多人看到。我们明天见!

for...of 的秘密:迭代器与可迭代对象,你也能创造“可循环”的东西

为什么数组可以用for...of循环?为什么对象不行?今天我们来揭开JS里“可循环”的秘密——迭代器(Iterator)和可迭代对象(Iterable)。弄懂它们,你就能让自己的对象也支持for...of,甚至还能写出像Python生成器那样优雅的代码。

前言

你有没有好奇过,为什么数组可以用for...of遍历,而对象不行?为什么...扩展运算符可以展开数组,却不能直接展开对象?这背后其实是迭代器协议在起作用。

今天我们就来彻底搞懂这套机制,然后亲手造一个可以for...of遍历的对象。看完你会感叹:原来JS的循环还有这么多骚操作!

一、什么是可迭代对象?

如果一个对象实现了可迭代协议,它就是可迭代对象。可迭代协议要求对象有一个[Symbol.iterator]方法,这个方法返回一个迭代器

简单来说:可迭代对象 = 有一个能返回迭代器的方法

数组、字符串、Map、Set、arguments、NodeList等都是原生可迭代对象。所以你可以:

for (let item of [1,2,3]) { console.log(item); } // 数组
for (let char of 'hello') { console.log(char); } // 字符串
for (let [key,val] of new Map([[1,2]])) { } // Map

对象不是可迭代对象,所以for...of直接遍历对象会报错。

二、迭代器长什么样?

迭代器是一个对象,它有一个next()方法。每次调用next(),会返回一个对象:{ value: 任意值, done: boolean }done表示是否遍历结束。

比如手动创建一个数组的迭代器:

const arr = ['a', 'b', 'c'];
const iterator = arr[Symbol.iterator]();

console.log(iterator.next()); // { value: 'a', done: false }
console.log(iterator.next()); // { value: 'b', done: false }
console.log(iterator.next()); // { value: 'c', done: false }
console.log(iterator.next()); // { value: undefined, done: true }

你看,这个迭代器就像个“读取器”,每次取一个值,直到取完。

三、自己实现一个可迭代对象

现在我们来造一个可以for...of遍历的对象。比如一个范围对象,能遍历从start到end的所有整数。

const range = {
  start: 1,
  end: 5,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end;
    return {
      next() {
        if (current <= end) {
          return { value: current++, done: false };
        } else {
          return { value: undefined, done: true };
        }
      }
    };
  }
};

for (let num of range) {
  console.log(num); // 1,2,3,4,5
}

就这么简单!只要对象有[Symbol.iterator]方法,并且返回一个带有next的对象,它就能被for...of遍历。

四、扩展运算符、解构赋值背后的迭代器

很多JS语法都依赖迭代器:

  • ...扩展运算符:把可迭代对象展开成元素列表
  • 数组解构:[a, b, ...rest] = iterable
  • Array.from():把可迭代对象转成数组
  • for...of循环
  • Promise.all()Promise.race()的参数也是可迭代对象

所以,只要你的对象是可迭代的,它就能享受这些语法糖。

const numbers = [...range]; // [1,2,3,4,5]
const [first, second, ...rest] = range; // first=1, second=2, rest=[3,4,5]

五、生成器函数:迭代器的快捷方式

还记得昨天的Generator吗?生成器函数返回的就是迭代器!所以我们可以用Generator来简化上面的代码:

const range = {
  start: 1,
  end: 5,
  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) {
      yield i;
    }
  }
};

是不是简洁多了?*[Symbol.iterator]()就是Generator方法,每次yield一个值,for...of会自动调用next

六、无限迭代器:永不停止的循环

迭代器可以无限进行下去,比如生成斐波那契数列:

const fibonacci = {
  *[Symbol.iterator]() {
    let a = 0, b = 1;
    while (true) {
      yield a;
      [a, b] = [b, a + b];
    }
  }
};

const fib = fibonacci[Symbol.iterator]();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
// 想取多少取多少

但注意:用for...of遍历无限迭代器会死循环,所以要手动控制。

七、提前终止迭代器:return方法

如果迭代器被提前终止(比如for...of中遇到break,或者解构只取前几个值),JS会调用迭代器的return方法(如果有的话)。这可以用来做清理工作。

const specialIterable = {
  [Symbol.iterator]() {
    let i = 0;
    return {
      next() {
        if (i < 3) return { value: i++, done: false };
        return { done: true };
      },
      return() {
        console.log('提前终止了');
        return { done: true };
      }
    };
  }
};

for (let x of specialIterable) {
  console.log(x);
  if (x === 1) break; // 触发return
}
// 输出:0,1, 然后打印“提前终止了”

八、实际应用:让对象可迭代

假设你有一个用户列表对象,你想让它支持for...of直接遍历用户:

const userList = {
  users: [
    { name: '张三', age: 18 },
    { name: '李四', age: 20 },
    { name: '王五', age: 22 }
  ],
  *[Symbol.iterator]() {
    for (let user of this.users) {
      yield user;
    }
  }
};

for (let user of userList) {
  console.log(user.name); // 张三 李四 王五
}

这样,你的自定义对象就能像数组一样优雅地遍历了。

九、总结:迭代器无处不在

  • 可迭代对象:实现了[Symbol.iterator]方法,返回一个迭代器。
  • 迭代器:实现了next()方法,返回{ value, done }
  • 生成器函数:是迭代器最便捷的实现方式。
  • 很多JS语法(for...of、扩展运算符、解构)都依赖迭代器协议。

理解了这套机制,你就能:

  • 让自定义对象支持for...of
  • 创建无限序列
  • 深入理解JS语法糖背后的原理

下次你写for...of时,脑子里可以浮现出迭代器一步步next的画面——这才是真正掌握了JS的底层。

明天我们将进入DOM操作与事件流,从JS的核心走向与页面的交互。如果你觉得今天的文章够“可迭代”,点个赞让更多人看到。我们明天见!

产品:这个文字颜色能不能根据背景图自动换?

产品:这个文字颜色能不能根据背景图自动换?我:安排

当产品经理拿着两张背景图——一张深邃的午夜蓝、一张清新的樱花粉——问出这句话时,我知道,又要动脑子了。

事情是这样的

那天产品小哥跑过来,手里拿着两张设计稿:一张是深邃的午夜蓝纯色背景,另一张是清新的樱花粉渐变背景。

“你看啊,”他指着图上的文字区域,“我们的商品详情页,深色背景上用黑色字根本看不清,浅色背景上白字又太刺眼。能不能——让文字颜色自己适应背景?”

我看着他期待的小眼神,深吸一口气:“安排。”

需求拆解

其实这个需求很清晰:文字颜色需要根据背景图的颜色自动调整

更具体地说:

  • 深色背景 → 文字变浅色(白或浅灰)
  • 浅色背景 → 文字变深色(黑或深灰)

但如果只是简单判断黑白,遇到五颜六色的背景图(比如渐变、花纹)就不够用了。我们需要真正读懂背景图的主色调。

技术选型

要在前端实现这个功能,核心是读取图片的颜色信息。方案如下:

  1. 用 Canvas 绘制背景图
  2. 获取图片的像素数据
  3. 计算平均色或亮度
  4. 根据亮度决定文字颜色

没错,就这四步。下面开干。

编程的本质就是以数据为中心。  图片,说到底就是一个数组。数组的长宽对应图片的尺寸,而每个元素里存储着该像素的 RGBA 值——红、绿、蓝和透明度。我们要做的,就是读取这个数组,分析它的颜色分布,然后做出决策。这听起来很酷,对吧?

第一步:获取图片像素数据

function getImagePixels(image) {
  const canvas = document.createElement('canvas');
  const { naturalWidth: width, naturalHeight: height } = image;
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(image, 0, 0, width, height);
  const imageData = ctx.getImageData(0, 0, width, height).data;
  
  // 为了方便计算,返回二维数组 [x][y] = [r, g, b, a]
  const pixels = [];
  for (let x = 0; x < width; x++) {
    pixels[x] = [];
    for (let y = 0; y < height; y++) {
      const idx = (y * width + x) * 4;
      pixels[x][y] = [
        imageData[idx],     // R
        imageData[idx + 1], // G
        imageData[idx + 2], // B
        imageData[idx + 3]  // A
      ];
    }
  }
  return pixels;
}

这里有个坑需要注意:像素索引是 (y * width + x) * 4,别写错了,不然颜色就全乱了。

第二步:计算区域平均亮度

我们不需要全图平均,只计算文字所在区域的背景色即可,这样更精准。

function getAverageBrightness(pixels, xRange, yRange) {
  const [xMin, xMax] = xRange;
  const [yMin, yMax] = yRange;
  let rSum = 0, gSum = 0, bSum = 0;
  let count = 0;
  
  for (let x = xMin; x < xMax; x++) {
    if (!pixels[x]) continue;
    for (let y = yMin; y < yMax; y++) {
      if (!pixels[x][y]) continue;
      const [r, g, b] = pixels[x][y];
      rSum += r;
      gSum += g;
      bSum += b;
      count++;
    }
  }
  
  if (count === 0) return 128; // 默认中灰
  
  const avgR = rSum / count;
  const avgG = gSum / count;
  const avgB = bSum / count;
  
  // 人眼对绿色最敏感,亮度公式
  return 0.299 * avgR + 0.587 * avgG + 0.114 * avgB;
}

第三步:决定文字颜色

亮度范围 0~255,以 128 为分界:

function getTextColor(brightness) {
  return brightness > 128 ? '#000000' : '#FFFFFF';
}

第四步:整合到页面

const img = document.getElementById('bgImage');
const textElement = document.querySelector('.dynamic-text');

img.onload = () => {
  // 获取像素数据
  const pixels = getImagePixels(img);
  const width = pixels.length;
  const height = pixels[0]?.length || 0;
  
  // 文字通常在图片底部中央,取这个区域
  const textAreaX = [width * 0.3, width * 0.7];
  const textAreaY = [height * 0.7, height * 0.9];
  
  const brightness = getAverageBrightness(pixels, textAreaX, textAreaY);
  const textColor = getTextColor(brightness);
  
  textElement.style.color = textColor;
  
  // 可选:加个半透明底,更稳妥
  textElement.style.textShadow = brightness > 128 
    ? '0 0 2px rgba(0,0,0,0.3)' 
    : '0 0 2px rgba(255,255,255,0.3)';
};

// 跨域处理
img.crossOrigin = 'Anonymous';
if (img.complete) img.onload();

优化与坑点

1. 性能问题

图片很大时遍历所有像素会卡。采样降频:每隔 10 个像素取一次,速度提升 100 倍。

// 采样版
for (let x = 0; x < width; x += 10) {
  for (let y = 0; y < height; y += 10) {
    // 采样处理
  }
}

2. 跨域问题

如果图片是 CDN 上的,记得设置 crossOrigin,并且服务端要支持 CORS。

3. 图片加载

一定要在 onload 里处理,否则 Canvas 是空的。

4. 复杂背景怎么办

如果背景是渐变或复杂图案,纯黑白文字可能还不够。可以加一层半透明蒙层:

textElement.style.backgroundColor = brightness > 128 
  ? 'rgba(0,0,0,0.5)' 
  : 'rgba(255,255,255,0.5)';

最终效果

搞定之后,我拿给产品小哥演示:

  • 深色背景图 → 白色文字,带淡淡阴影
  • 浅色背景图 → 黑色文字,清晰可见
  • 花纹复杂的 → 自动取平均亮度,稳稳适配

产品小哥满意地点点头:“不错,安排上了。”

我也满意地点点头:又一个小需求,用技术优雅地解决了。

写在最后

这个方案的核心就三件事:画 Canvas、取像素、算亮度。代码量不大,但非常实用。

如果你也遇到类似的需求——无论是商品详情页、活动 banner,还是用户自定义背景——都可以用这套思路搞定。

最后送大家一句话:与其让产品经理追着你改颜色,不如让代码自己学会挑颜色。 你还遇到过什么奇葩需求 欢迎在评论区大声吐槽。

纯干货,前端字体极致优化!谷歌、阿里、字节、腾讯都在用的终极解决方案,Vue3 + Vite 直接抄,页面提速不妥协!

最近在做一个公网的小项目,本身是一个在线的海报编辑器,因为之前做的比较糙,最近有时间了,领导让优化一下。

问题主要集中在页面的加载速度上。

image.png

设计稿要求多字体质感、标题正文差异化排版,结果引入的字体包动辄几MB,中文字体甚至直奔10MB+。

页面首屏加载非常慢,但是删字体包设计那边过不去,不删吧用户等待时间过长,体验直线下滑,简直两难。

核心问题

不过别慌,稳住了!

先明确我们到底在解决什么问题,避免盲目优化。

其实主要几个问题:

  • 字体体积冗余:完整字体包包含上万字符,项目实际用到的不过几百个,甚至有可能就几个字,全量加载纯纯浪费。
  • 阻塞页面:字体包过大,加载过程中可能触发FOIT(文字隐形)、FOUT(文字闪烁),页面出现留白卡顿。
  • 多字体加载混乱:一个页面同时存在多种字体包同时引入的时候,基本上就是谁在前面加载谁,无规划加载拖慢整体渲染。
  • 格式不兼容:沿用TTF/OTF老式字体格式,体积大、压缩率低,完全适配不了现代前端性能要求。

但是格式问题需要注意,新式的字体包,比如说WOFF2,是不支持IE这种较老版本的浏览器的。

如果你有兼容需求,记得不要上新包。

解决方案

建议字体包转WOFF2

前提是你只要没有兼容性需求,几乎WOFF2必转的。

相比于传统TTF、OTF、WOFF格式,WOFF2是现代浏览器专属的字体压缩格式,体积能直接缩减50%-70%

一个TTF大约20MB的包,在WOFF2上大概也就8MB左右,这个优化是显而易见的。

而且Chrome、Edge、Safari、Firefox全版本兼容,完全不用担心兼容性问题。

可以用 Font Squirrel在线工具进行一键转换,或者直接让UI那边导出来WOFF2的包。

按场景拆分字体包

多字体包的情况下千万别全量引入,一定要按照使用频率、使用场景拆分:

高频使用的优先处理,低频使用的单独拆分,延后加载。

可以在引入的部分,按照字重、字体样式拆分@font-face

这样的好处在于浏览器只会加载当前页面用到的字体规则,不会全量请求所有字体包。

/* 按字重拆分,按需加载 */
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/regular.woff2') format('woff2');
  font-weight: 400;
}
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/bold.woff2') format('woff2');
  font-weight: 700;
}

延迟加载

另外文字留白、闪烁问题,核心就是靠font-display属性。

使用font-display: swap浏览器会先使用系统默认字体展示页面文字,等到自定义字体加载完成后,再无缝替换。

全程不会出现文字隐形、页面卡顿的情况,让用户感知上有一种等一小会儿的感觉。

还有就是可视区域以外的字体,完全没必要和首屏一起加载,等到页面加载完成、或者用户触发对应模块时,再加载字体即可,减少首屏的请求数量。

// 页面加载完成后,懒加载非首屏字体
window.addEventListener('load', () => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/css/other-font.css';
  document.head.appendChild(link);
})

字体子集化&按需加载(终极方案)

前面主要是给字体"减负",字体子集化和按需加载就是直接给字体"瘦身"。

据我所知,这也是目前谷歌、阿里、腾讯等大厂通用的天花板方案,能够彻底解决字体冗余问题。

核心原理

完整字体包中包含海量未使用字符,这样我们其实可以通过工具提取项目实际用到的字符

将大字体拆分成多个极小的字体分片;再通过CSS的unicode-range告诉浏览器,哪些字符对应哪个字体分片。

浏览器只会加载当前页面用到的分片,没用到的完全不请求。

相当于是对通过拆字的方式实现了对字体包的懒加载。

简单画一个流程图:

image.png

这套方案下来,原本几MB的字体,能直接压缩到几十KB,首屏字体加载速度提升数十倍,还完全不影响多字体使用!

具体实现

这里我推荐几个我用过的:glyphhangerfontminvite-plugin-fontmin

glyphhanger

基于Node.js,无需手动提取字符,直接爬取页面文字,自动生成子集字体+unicode-range CSS,适合快速优化现有页面,新手也能一键上手。

# 全局安装
npm install -g glyphhanger
# 一键生成子集字体与CSS
glyphhanger http://localhost:3000 --formats=woff2 --subset=./src/fonts/xxx.ttf

fontmin,纯JS定制化工具

纯JavaScript实现,无额外环境依赖,支持自定义提取字符、批量处理,可嵌入Webpack、Gulp等构建流程,适合需要定制化优化的项目。

const Fontmin = require('fontmin')
new Fontmin()
  .src('./src/fonts/xxx.ttf')
  .use(Fontmin.glyph({ text: '项目实际用到的文字', hinting: false }))
  .use(Fontmin.ttf2woff2())
  .dest('./dist/fonts')
  .run()

vite-plugin-fontmin,Vue3+Vite项目专属

# 安装插件
npm install vite-plugin-fontmin -D
# 或者yarn/pnpm
pnpm add vite-plugin-fontmin -D

配置文件,这里写的比较全,包含字体优化、资源打包、开发环境优化等等,可根据项目字体自行修改。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入字体子集化插件
import fontminPlugin from 'vite-plugin-fontmin'

export default defineConfig({
  // 路径别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  // 插件配置
  plugins: [
    vue(),
    // 字体子集化核心配置
    fontminPlugin({
      // 配置多个字体(单字体直接写单个对象即可)
      fonts: [
        {
          // 源字体文件路径(放入项目src/fonts目录下)
          fontSrc: './src/fonts/SourceHanSansCN-Regular.ttf',
          // 子集化后字体的输出目录
          fontDest: './src/assets/fonts/subset/',
          // 自动扫描项目文件,提取所有用到的字符(无需手动书写)
          inputPath: ['./src/**/*.{vue,ts,tsx,js,jsx,css,scss}'],
          // 额外预留字符(动态内容、用户输入、接口返回文字,提前预留)
          input: '0123456789qwertyuiopasdfghjklzxcvbnm,。!?;:“”‘’',
          // 仅输出WOFF2格式
          formats: ['woff2'],
          // 开启unicode-range按需加载(核心)
          unicodeRange: true,
          // 字体渲染规则,避免阻塞
          fontDisplay: 'swap',
          // 关闭字体提示,进一步压缩体积
          hinting: false
        },
        // 多字体配置示例(标题字体,按需添加)
        {
          fontSrc: './src/fonts/TitleFont-Bold.ttf',
          fontDest: './src/assets/fonts/subset/title/',
          inputPath: ['./src/components/Title/**/*.vue', './src/views/**/*.vue'],
          formats: ['woff2'],
          unicodeRange: true,
          fontDisplay: 'swap'
        }
      ],
      // 开发环境仅执行一次子集化,避免热更新卡顿
      runOnceInDev: true,
      // 生产环境压缩字体
      compress: true
    })
  ],
  // 生产构建配置
  build: {
    assetsDir: 'static/assets',
    // 字体资源单独打包,方便缓存
    rollupOptions: {
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.woff2')) {
            return 'static/assets/fonts/[name]-[hash][extname]'
          }
          return 'static/assets/[name]-[hash][extname]'
        }
      }
    },
    // 关闭生产环境sourcemap,提升打包速度
    sourcemap: false,
    // 代码压缩
    minify: 'terser'
  },
  // 开发服务器配置
  server: {
    port: 3000,
    open: true
  }
})

完成上述配置以后,插件会自动生成对应的@font-face规则,无需手动引入字体CSS,直接在项目样式里使用即可。

/* src/assets/css/global.css */
body {
  font-family: 'SourceHanSansCN', sans-serif;
}
.title {
  font-family: 'TitleFont', sans-serif;
  font-weight: 700;
}

总结

其实字体优化一直是老大难问题,不上字体包效果出不来,上了字体包加载速度上不来。

当然,我们仍然建议,非必要不要上字体包。

还有一个"邪修"方案,可以手动创建字体包子集,也就是说这个字体包只包含要用字,其他的删掉。(参考iconFont的字体库"下载子集")。

如果非要上,那就是所有字体转WOFF2,拆分字体包,添加font-display: swap

另外再增加字体子集化和按需加载的部分,让首屏加载快起来。

你还在给每个图片父元素加类名?CSS :has() 让选择器“逆天改命”

引言

“组长,这个需求我写不了。”

“什么需求?”

“产品经理说,所有包含图片的卡片,要在卡片上加一个‘带图标识’的边框。但是这些卡片是动态渲染的,图片可有可无,我总不能每个卡片都写个条件判断吧?”

组长瞥了我一眼:“你用 CSS 啊。”

“CSS 怎么选?CSS 又没办法判断一个元素里有没有图片……”

组长微微一笑:“那是以前的 CSS 了。你知道 :has() 吗?它能让父元素根据子元素的状态来改变自己。简单来说,就是 ‘子凭父贵’的反过来——父凭子贵。”

我当时一脸懵:还有这种操作?

那天下午,我学会了 :has(),然后发现——原来 CSS 早就不是当年的 CSS 了。它悄悄给自己装了个“逆向思维”的外挂,只是我们都不知道。

一、:has() 是什么?CSS 的“时光倒流”

在 CSS 选择器的历史上,我们一直只能从上往下选:父元素 → 子元素,兄弟元素 → 相邻兄弟。比如 div p 选择 div 里的所有 p,h1 + p 选择紧跟在 h1 后面的 p。

但从来没有人能根据子元素的状态来选择父元素。直到 :has() 出现。

:has() 是一个关系伪类,它允许你根据元素的后代或后续兄弟元素来匹配该元素。语法看起来就像是在问:“嘿,这个元素里面有没有符合某个条件的子元素?”

/* 选择所有包含 <img> 元素的 <figure> */
figure:has(img) {
  border: 2px solid gold;
}

/* 选择所有包含 .error-message 的表单 */
form:has(.error-message) {
  border: 1px solid red;
  background-color: #ffeeee;
}

更妙的是,:has() 里面可以写几乎任何复杂选择器,包括伪类、组合器,甚至可以嵌套 :has()

二、实战:那些让你拍大腿的场景

2.1 场景一:包含图片的卡片加特殊样式

终于不用 JS 了!

<div class="card">
  <h3>标题</h3>
  <p>一些文字...</p>
  <img src="photo.jpg" alt="配图">
</div>
<div class="card">
  <h3>标题</h3>
  <p>没有图片的卡片</p>
</div>
.card:has(img) {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  border-left: 4px solid #ff8800;
}

只有带图片的卡片才会获得橙色左边框,干净利落。

2.2 场景二:表单实时校验反馈(不用 JS 监听)

/* 如果有无效输入框,给表单加个红框 */
form:has(input:invalid) {
  border: 2px solid red;
  padding: 10px;
}

/* 如果有被选中的复选框,给父级加个标记 */
fieldset:has(input[type="checkbox"]:checked) {
  background-color: #e0ffe0;
}

这比以前用 JS 监听每个 input 然后给父级加类名优雅太多。

2.3 场景三:空状态提示

/* 如果列表里没有 li,显示空状态提示 */
ul:not(:has(li))::after {
  content: "暂无数据";
  display: block;
  color: #999;
  text-align: center;
}

:not(:has(...)) 这个组合很有用,表示“没有子元素满足条件”。

2.4 场景四:兄弟元素的影响

:has() 不仅可以选祖先,还可以选兄弟?

/* 如果 h2 后面紧跟着 p,给 h2 加下划线 */
h2:has(+ p) {
  text-decoration: underline;
}

这利用了 + 组合器,+ p 表示“后面紧邻的 p”,所以 h2:has(+ p) 就是“后面有 p 的 h2”。实际上 :has() 里的选择器可以往后看。

2.5 场景五:多级嵌套的“父选择”

/* 如果某个 section 里有一个 article,且 article 内有 img,给 section 加背景 */
section:has(article:has(img)) {
  background: #fafafa;
}

这就是嵌套 :has(),越看越像 XPath,但威力巨大。

三、:has() 的“阴暗面”:性能与兼容

这么强大的东西,有没有什么坑?

3.1 兼容性

:has()CSS 选择器 Level 4 的一部分。它在 Chrome 105+、Edge 105+、Firefox 121+、Safari 15.4+ 开始支持。也就是说,2023 年以后的主流浏览器基本都能用。但对于老浏览器,需要做降级处理(比如用 JS 回退)。

3.2 性能考虑

:has() 被称为“昂贵的选择器”,因为它需要检查元素的后代或后续兄弟,浏览器可能需要做更多工作。但现代浏览器已经做了大量优化,在合理使用下不会明显影响性能。不要滥用,比如不要给每个元素都加上 :has(*) 这种通配。

最佳实践:尽量限定范围,比如 nav:has(> a.active)*:has(a) 高效得多。

3.3 一些你不能做(或不应做)的事

  • 不能在 :has() 里使用 :has() 自身形成循环引用?理论上可以,但你会把自己绕晕。
  • 不能用 :has() 选择祖先的祖先?它可以,但性能会下降。
  • 不能用 :has() 来改变页面结构?它只是选择器,只能应用样式,不能添加或删除元素。

四、还有哪些“逆天”的新选择器?

:has() 同期或稍早,CSS 还引入了:

  • :where():优先级为 0,用于降低选择器权重。
  • :is():可以写一组选择器,比如 :is(header, main, footer) p
  • :not() 也升级了,可以接受复杂选择器列表。
  • @scope 实验性功能,可以限定样式的作用域。

这些新特性正在把 CSS 从“声明式样式表”变成“轻量级逻辑引擎”。

五、总结:CSS 不再是“语言残疾”

以前我们常开玩笑说:“CSS 不是编程语言。”现在,有了 :has(),CSS 居然能根据子元素来决定父元素样式,这几乎就是一种“条件判断”能力。

:has() 的出现,让我们可以少写很多 JavaScript 类名操作,让样式更纯粹、更内聚。虽然兼容性还没到 100%,但已经值得我们在现代项目中尝试。

下次产品经理再提“根据子元素内容改变父元素样式”的需求,你可以自信地说:“交给 CSS,不用写 JS。”


每日一问:你还遇到过哪些用 JS 实现很麻烦,但 CSS 新特性可以轻松解决的问题?评论区分享,一起刷新认知!

SwiftUI 如何实现 Infinite Scroll?

欢迎点个 star:github.com/RickeyBoy/R…

面试题:用 SwiftUI 实现一个无限滚动列表,支持分页加载。

这道题我在面试中遇到过好几次,说实话第一次答的时候以为随便写个 LazyVStack + onAppear 就完事了。后来才发现,面试官真正想考的不是你会不会用 API,而是你对状态管理、性能优化、Task 生命周期这些东西到底理解多深。

我的思路是从最简方案出发,一步步暴露问题、一步步优化。在开始写代码之前,先聊一下架构选型。

为什么选 MVVM?

先说一下 SwiftUI 里常见的架构选择。MVC 就不聊了,那是 UIKit 时代的标配,Controller 跟 UIKit 强耦合,到了 SwiftUI 里根本没有 UIViewController 这个角色,MVC 自然也就退出舞台了。

SwiftUI 里最常见的架构,从简单到复杂大概是这么几个:

架构 特点 适合场景
MV(Model-View) 没有 ViewModel,状态直接放 View 里,Apple 官方示例的典型写法 逻辑简单的页面
MVVM 抽出 ViewModel 管理状态和逻辑,SwiftUI 里最主流的选择 中等复杂度,需要可测试性
TCA 单向数据流,State + Action + Reducer + Effect,强约束 大型项目,需要严格的状态管理

其中 MV 是最基础的,逻辑简单的页面,@State 往 View 里一放就完事了,Apple 自己的 WWDC 示例大量都是这么写的。但 infinite scroll 涉及分页状态、加载状态、错误处理、Task 生命周期管理这些东西,全塞 View 里会很乱。抽一个 ViewModel 出来专门管理这些状态,View 只负责渲染和转发用户操作,职责就清晰多了。

所以这道题用 MVVM 是最合适的,不是因为 MVVM 最好,而是这个场景的复杂度刚好适合。并且采用 MVVM 结构规整,可拓展性也强,从面试回答的角度来讲也是正好的。

而 SwiftUI 天然就鼓励这种模式,@Observable 本身就是 binding 机制,ViewModel 状态一变,View 自动更新,不需要手动同步。我们后面的代码就是按这个思路来的。

一、最小可用版本

先写一个能跑的最简版本。

核心思路很简单:LazyVStack 只在 item 即将可见时才实例化 View,我们利用 onAppear 检测"最后一个 item 出现了",然后触发下一页请求。

Model

struct Item: Identifiable, Equatable {
    let id: String
    let title: String
}

struct PageInfo {
    let endCursor: String?
    let hasNextPage: Bool
}

ViewModel

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private var pageInfo: PageInfo?

    func loadNextPage() async {
        let response = try? await APIService.fetchItems(after: pageInfo?.endCursor)
        guard let response else { return }
        items.append(contentsOf: response.items)
        pageInfo = response.pageInfo
    }
}

View

struct ItemListView: View {
    @State private var viewModel = ItemListViewModel()

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(viewModel.items) { item in
                    ItemRow(item: item)
                        .onAppear {
                            if item == viewModel.items.last {
                                Task { await viewModel.loadNextPage() }
                            }
                        }
                }
            }
        }
        .task { await viewModel.loadNextPage() }
    }
}

代码很短,逻辑也直白:

  1. 每当最后一个 item 出现在屏幕上,就触发 loadNextPage()
  2. loadNextPage() 请求后台去 fetch,拿到数据然后塞进 items 中
  3. View 检测到有更新,自动刷新页面

一句话总结:最后一个 item onAppear 的时候,就进行请求。

1.1 分页方式:cursor vs offset

可能有同学会问:为什么 fetchItems(after: cursor) 用的是 cursor,而不是传统的 pageoffset

分页一般有两种方式:

  • Offset-basedfetchItems(page: 3, size: 20),按页码或偏移量取数据
  • Cursor-basedfetchItems(after: "abc123"),传上一页最后一条的标识,从那里往后取

对于 infinite scroll 这种场景,cursor-based 更合适。详细对比一下:

Cursor-based Offset-based
数据一致性 不受中间插入/删除影响 插入新数据会导致重复或遗漏
性能 数据库只需定位到 cursor 后续 大 offset 需要 skip N 行
适用场景 实时 feed、社交流 固定数据集、后台管理列表

简单来说,cursor-based 更适合"数据随时在变"的场景(比如社交 feed),offset-based 更适合"数据基本不变"的场景(比如后台管理列表)。infinite scroll 的数据通常是动态的,所以用 cursor-based。

1.2 LazyVStack vs List

可能有同学会问:为什么用 LazyVStack 而不是 List

先说浅显的回答:LazyVStack 布局更自由,没有 List 自带的分割线、背景色、cell 样式这些限制,适合高度自定义的 UI。而 List 开箱即用,自带滑动删除、拖拽排序这些交互,适合标准列表场景。

当然,如果想要深入回答,还有可以继续。二者还有一个关键区别其实是内存模型

LazyVStack List
View 回收 ❌ 不回收,创建后常驻内存 ✅ 内部回收机制
内存增长 随滚动距离线性增长 基本恒定
自定义布局 完全自由 受限于 List 样式
万级数据 可能有内存压力 表现更好

为什么会有这个区别?因为它们底层的实现不一样。List 底层是基于 UICollectionView(iOS 16 之前是 UITableView),天然有 cell 回收复用机制,滚出屏幕的 cell 会被回收,滚入时再复用,所以内存占用基本恒定。而 LazyVStack 底层只是一个普通的布局容器,"Lazy" 的意思是延迟创建,item 滚入可见区域时才创建 View,但创建之后就一直留在内存里,不会回收。

所以如果列表数据量很大(比如社交 feed 那种上万条的),List 在内存上更有优势。如果需要高度自定义的 UI,那就用 LazyVStack,但要心里有数:用户滚得越远,内存占用越大。

1.3 为什么加 @MainActor

上面的 ViewModel 代码加了 @MainActor,这个很容易被忽略但其实很关键。

@Observable 本身不会自动保证在主线程更新状态。而我们的 loadNextPage() 是在 Task 里通过 await 拿数据,await 之后的代码在哪个线程执行是不确定的。如果恰好在后台线程执行了 items.append(...),SwiftUI 收到状态变更通知后会在后台线程刷新 UI,这就会导致紫色警告("Publishing changes from background threads is not allowed")甚至崩溃。

加上 @MainActor 之后,这个类的所有属性访问和方法调用都会被隔离到主线程,从根源上避免线程安全问题。

另外补充一下:Swift 6.2(Xcode 26)引入了模块级别的 Default Actor Isolation 设置,可以把整个模块的默认隔离改为 MainActor,开启之后所有类型都默认跑在主线程,不用再手动加 @MainActor。但这是一个 opt-in 的设置,默认值还是 nonisolated,而且不是所有项目都会立刻升级。所以目前来说,显式写 @MainActor 仍然是更稳妥的做法。

1.4 几个小细节

有几个代码细节,不影响功能,但代码质量会好不少,属于面试加分项。

private(set) 控制可见性

itemsprivate(set) 修饰,外部只能读不能写。这样 View 就没法直接改 items,所有数据变更都必须经过 ViewModel 的方法,数据流向是单向的。这个习惯在 MVVM 里很重要,不然 View 和 ViewModel 的职责边界很容易模糊。

让 Item 遵循 Equatable

上面的代码里 Item 已经加了 Equatable,所以判断"是不是最后一个"可以直接写 item == viewModel.items.last,不用绕一圈去比 id。后面加叠加更多功能的时候也可以用,代码更简洁。

.task = .onAppear + Task

View 里首次加载用的是 .task { await viewModel.loadNextPage() },这其实等价于在 .onAppear 里手动创建一个 Task。但 .task 有个好处:当 View 消失时会自动 cancel 这个 Task。手动写 Task {} 的话你得自己管 cancel,容易漏掉,所以首次加载优先用 .task

二、防重复请求

上一个最基础的版本,有个明显的问题:快速滚动时 onAppear 有可能会被多次触发,导致同一页被重复请求了。

怎么解决?思路也很直接:加一个 isLoading 标记 + hasNextPage 判断,双重 guard,然后通过这些属性来判断是否需要发送请求。

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private var pageInfo: PageInfo?

    var canLoadMore: Bool {
        guard let pageInfo else { return items.isEmpty } // 首次加载
        return pageInfo.hasNextPage && !isLoading
    }

    func loadNextPage() async {
        guard canLoadMore else { return }
        isLoading = true
        defer { isLoading = false }

        let response = try? await APIService.fetchItems(after: pageInfo?.endCursor)
        guard let response else { return }
        items.append(contentsOf: response.items)
        pageInfo = response.pageInfo
    }
}

canLoadMore 这个 computed property 干了两件事:

  • 没有下一页时不请求(通过后端返回的 pageInfo.hasNextPage 来判断)
  • 正在加载时不重复请求(通过 isLoading 来判断)

2.1 小细节

defer 管理状态翻转

注意 isLoading 的写法:开头设为 true,然后紧接着 defer { isLoading = false }。这样不管后面是正常返回还是提前 returnisLoading 都会被重置回 false

如果不用 defer,你就得在每个 return 之前手动加一句 isLoading = false,路径一多很容易漏掉,漏掉的后果就是列表永远卡在 loading 状态,再也加载不了下一页。

canLoadMore 作为 computed property

把"能不能加载"的判断收到一个 computed property 里,而不是在 loadNextPage() 里写一堆 if。好处是逻辑集中,后面要加新条件(比如错误状态下不加载)直接改这一个地方就行,调用方不用动。

三、提前预加载:Threshold Prefetch

目前的逻辑是"最后一个 item 出现了才开始加载",那么用户的感受就是:滚到底 → 停顿 → 等数据 → 新数据出现。那个停顿虽然可能只有几百毫秒,但体感上还是挺明显的。

怎么办?提前触发。 不等最后一个 item,而是在还剩 N 个 item 时就开始加载下一页。

// View
ForEach(viewModel.items) { item in
    ItemRow(item: item)
        .onAppear { viewModel.onItemAppear(item) } // View 层仅透传,将逻辑交给 ViewModel
}

// ViewModel,新增 prefetch threshold
private let prefetchThreshold = 5

func onItemAppear(_ item: Item) {
    guard let index = items.firstIndex(of: item),
          index >= items.count - prefetchThreshold else { return } // 判断是否该加载下一页了
    Task { await loadNextPage() }
}

这样一来,用户还剩 5 个 item 可以滚的时候,网络请求就已经在跑了,等滚到底部时,数据大概率已经回来了,体验上就是"无缝衔接"。

那 threshold 到底设多少合适?这个纯属经验值,根据具体的数据量、UI 复杂度都相关,5 只是一个经验值。总的来讲就是一个 trade-off:

  • threshold 太小:快速滚动还是会看到停顿
  • threshold 太大:用户可能只看前几条就走了,白白浪费请求

四、Task 取消 + 错误处理

到这里基本功能已经没问题了。接下来聊聊 Task 生命周期管理和错误恢复,这部分在面试里属于加分项。

4.1 Task 取消

为什么需要管理 Task 取消?我们目前的例子中,单一列表的情况可能不需要考虑。但是如果是搜索页面的列表,或者叠加筛选功能,问题就复杂了。

举个具体的例子:

  1. 用户在搜索页搜"咖啡",然后在列表页向下滑动,触发了一个 loadNextPage 的请求 A
  2. 还没等数据回来,用户改成搜"奶茶",请求 B 又发出去了
  3. 这时候网络上同时有两个请求在跑。如果请求 B 先于 A 回来,那么等请求 A 回来的时候,用户就会发现明明搜索的是“奶茶”,但是却又展示了不少“咖啡”内容。

这种 bug 不是每次都能复现(取决于网络时序),但一旦出现用户会很困惑,所以解决方式就是:发新请求前先 cancel 旧的,被 cancel 的任务即便返回了 response 也不处理

@MainActor @Observable
final class ItemListViewModel {
    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: Error?
    private var pageInfo: PageInfo?
    private var loadTask: Task<Void, Never>? // 💾 持有当前请求的引用

    func loadNextPage() {
        guard canLoadMore else { return }
        loadTask?.cancel() // ❌ 发新请求前,先 cancel 旧的
        isLoading = true

        loadTask = Task { [weak self] in // 🔒 weak self 防止循环引用
            guard let self else { return }
            defer { self.isLoading = false }

            do {
                let response = try await APIService.fetchItems(after: pageInfo?.endCursor)
                guard !Task.isCancelled else { return } // 🛡️ 被 cancel 了就不写入
                self.items.append(contentsOf: response.items)
                self.pageInfo = response.pageInfo
                self.error = nil
            } catch {
                guard !Task.isCancelled else { return } // 🛡️ 同上
                self.error = error
            }
        }
    }

    func reset() {
        loadTask?.cancel() // ❌ 先 cancel,再清空
        items = []
        pageInfo = nil
        isLoading = false
        error = nil
    }

    // ...
}

4.2 错误重试

错误处理其实是一个很容易被忽略,同时也非常复杂的事情。这里我们的方案是当出现错误的时候,展现一个重试按钮。从 UI 的角度来讲不好看,但实际上面试阶段时间有限,能够展示出有错误处理的思维就可以了。

@MainActor @Observable
final class ItemListViewModel {
    // ...
    private(set) var error: Error?

    func retry() {
        error = nil
        loadNextPage()
    }
}
// View — 列表底部
if viewModel.error != nil {
    RetryButton { viewModel.retry() }
} else if viewModel.isLoading {
    ProgressView()
}

4.3 空状态处理

还有一个容易忽略的边界情况:首次加载完成后,后端返回了 0 条数据。

当前的代码里,items 为空有两种可能:一种是"还在加载第一页",另一种是"加载完了但确实没数据"。如果不区分这两种状态,用户看到的就是一片空白,不知道是在等数据还是真的没有内容。

处理方式也很简单,加一个 computed property 判断一下:

var isEmpty: Bool {
    !isLoading && items.isEmpty && error == nil && pageInfo != nil
}

这里的关键是 pageInfo != nil,说明至少请求过一次了(首次加载前 pageInfonil)。四个条件同时满足,才说明"确实没数据"。

View 里对应的处理:

if viewModel.isEmpty {
    ContentUnavailableView("暂无数据", systemImage: "tray")
} else if viewModel.isLoading && viewModel.items.isEmpty {
    ProgressView() // 首次加载中
} else {
    // 正常的列表内容
}

这样用户就能清楚地区分"加载中"和"没有数据"这两种状态了。

4.4 用 enum 收敛 View 状态

到这里你会发现,View 层需要处理的状态越来越多:首次加载中、有数据、空数据、出错。如果全用 if/else if 判断,条件一多很容易写乱,漏掉某个分支也不会有编译器提醒。

可以定义一个 enum 来收敛这些状态:

enum ViewState {
    case initialLoading    // 首次加载中
    case loaded            // 有数据,正常展示列表
    case empty             // 加载完了但没数据
    case error(String)     // 出错了
}

然后在 ViewModel 里加一个 computed property,从现有属性推导出当前的 View 状态:

var viewState: ViewState {
    if let error, items.isEmpty {
        return .error(error.localizedDescription)
    }
    if isLoading && items.isEmpty {
        return .initialLoading
    }
    if isEmpty {
        return .empty
    }
    return .loaded
}

注意这里的关键:ViewState 是 computed property,不是存储属性。底层的数据源还是 isLoadingitemserrorpageInfo 这些独立属性,viewState 只是把它们组合成 View 更容易消费的形式。这样既不会出现之前 LoadingState enum 耦合状态的问题,又让 View 的代码变得很干净:

var body: some View {
    Group {
        switch viewModel.viewState {
        case .initialLoading:
            ProgressView()
        case .empty:
            ContentUnavailableView("暂无数据", systemImage: "tray")
        case .error(let message):
            ErrorView(message: message) { viewModel.retry() }
        case .loaded:
            ScrollView {
                LazyVStack(spacing: 0) {
                    ForEach(viewModel.items) { item in
                        ItemRow(item: item)
                            .onAppear { viewModel.onItemAppear(item) }
                    }
                    loadingFooter
                }
            }
        }
    }
    .task { viewModel.loadNextPage() }
}

switch 替代 if/else if,每个分支对应一种状态,漏掉任何一个编译器都会报错。用 Group 包裹 switch 是为了能在外层挂 .task 触发首次加载。

五、完整代码

前面一步步拆解完了,最后把所有东西整合到一起。先看一下整体架构:

graph LR
    View -->|用户操作| ViewModel
    ViewModel -->|状态更新| View
    ViewModel -->|网络请求| APIService
    APIService -->|响应数据| ViewModel

    style View fill:#E8F5E9,stroke:#4CAF50
    style ViewModel fill:#E3F2FD,stroke:#2196F3
    style APIService fill:#FFF3E0,stroke:#FF9800

View 只管渲染和转发用户操作,ViewModel 管状态和请求编排,APIService 做实际的网络调用。数据流向是单向的:用户操作 → ViewModel 处理 → APIService 请求 → 数据回来更新状态 → View 自动刷新。

Model

struct Item: Identifiable, Equatable {
    let id: String
    let title: String
}

struct PageInfo: Equatable {
    let endCursor: String?
    let hasNextPage: Bool
}

struct PagedResponse {
    let items: [Item]
    let pageInfo: PageInfo
}

ViewState

enum ViewState {
    case initialLoading
    case loaded
    case empty
    case error(String)
}

ViewModel

@MainActor @Observable
final class ItemListViewModel {
    // MARK: - State

    private(set) var items: [Item] = []
    private(set) var isLoading = false
    private(set) var error: Error?

    // MARK: - Private

    private let prefetchThreshold = 5
    private var pageInfo: PageInfo?
    private var loadTask: Task<Void, Never>?

    // MARK: - Computed

    var canLoadMore: Bool {
        guard !isLoading else { return false }
        guard let pageInfo else { return items.isEmpty }
        return pageInfo.hasNextPage
    }

    var isEmpty: Bool {
        !isLoading && items.isEmpty && error == nil && pageInfo != nil
    }

    var viewState: ViewState {
        if let error, items.isEmpty {
            return .error(error.localizedDescription)
        }
        if isLoading && items.isEmpty {
            return .initialLoading
        }
        if isEmpty {
            return .empty
        }
        return .loaded
    }

    // MARK: - Trigger

    func onItemAppear(_ item: Item) {
        guard let index = items.firstIndex(of: item),
              index >= items.count - prefetchThreshold else { return }
        loadNextPage()
    }

    // MARK: - Actions

    func loadNextPage() {
        guard canLoadMore else { return }
        loadTask?.cancel()
        isLoading = true

        loadTask = Task { [weak self] in
            guard let self else { return }
            defer { self.isLoading = false }

            do {
                let response = try await APIService.fetchItems(after: pageInfo?.endCursor)
                guard !Task.isCancelled else { return }
                self.items.append(contentsOf: response.items)
                self.pageInfo = response.pageInfo
                self.error = nil
            } catch is CancellationError {
                // Task was cancelled, do nothing
            } catch {
                guard !Task.isCancelled else { return }
                self.error = error
            }
        }
    }

    func retry() {
        error = nil
        loadNextPage()
    }

    func reset() {
        loadTask?.cancel()
        items = []
        pageInfo = nil
        isLoading = false
        error = nil
    }
}

View

struct ItemListView: View {
    @State private var viewModel = ItemListViewModel()

    var body: some View {
        Group {
            switch viewModel.viewState {
            case .initialLoading:
                ProgressView()
            case .empty:
                ContentUnavailableView("暂无数据", systemImage: "tray")
            case .error(let message):
                ErrorView(message: message) { viewModel.retry() }
            case .loaded:
                ScrollView {
                    LazyVStack(spacing: 0) {
                        ForEach(viewModel.items) { item in
                            ItemRow(item: item)
                                .onAppear { viewModel.onItemAppear(item) }
                        }
                        loadingFooter
                    }
                }
            }
        }
        .task { viewModel.loadNextPage() }
    }

    @ViewBuilder
    private var loadingFooter: some View {
        if viewModel.error != nil {
            VStack(spacing: 8) {
                Text("加载失败")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Button("Retry") { viewModel.retry() }
                    .buttonStyle(.bordered)
            }
            .frame(maxWidth: .infinity)
            .padding()
        } else if viewModel.isLoading {
            ProgressView()
                .frame(maxWidth: .infinity)
                .padding()
        }
    }
}

总结

回顾一下整个思路:

  1. 从简单方案说起LazyVStack + onAppear last item,先把原理讲清楚
  2. 暴露问题并优化 — 重复请求 → guard;体验停顿 → threshold prefetch
  3. 展示工程素养 — Task 取消、error handling、retry
  4. 完整架构 — View 只渲染 + 转发,ViewModel 管状态 + 编排

Generator 函数:那个能“暂停”的函数,到底有什么用?

你有没有想过,如果函数可以“暂停”,等你想好了再继续,会是什么样?今天我们就来认识JavaScript里的“时间管理大师”——Generator函数。它能让你在执行到一半的时候停下来,等你喊“继续”再往下走。这听起来有点科幻,但它却是async/await的祖师爷。

前言

普通函数就像一支穿云箭,发射出去就直奔终点,中间绝不回头。但有时候我们需要更灵活的控制:比如我要分几步做一件事,每一步之间可能隔着十万八千里,或者我想让调用方决定什么时候继续。

Generator函数就是来解决这个问题的。它让你可以“暂停”函数执行,等会儿再“恢复”。这就像打游戏时按了暂停键,你去泡个面,回来继续打。

一、Generator长啥样?

Generator函数在function后面加个星号*,里面用yield关键字来“暂停”。

function* myGenerator() {
  console.log('第一步');
  yield '暂停一下';
  console.log('第二步');
  yield '再停一下';
  console.log('第三步');
  return '结束了';
}

调用这个函数并不会立即执行,而是返回一个迭代器对象。你通过调用next()来一步步执行。

const gen = myGenerator();

console.log(gen.next()); // 输出:第一步,{ value: '暂停一下', done: false }
console.log(gen.next()); // 输出:第二步,{ value: '再停一下', done: false }
console.log(gen.next()); // 输出:第三步,{ value: '结束了', done: true }
console.log(gen.next()); // { value: undefined, done: true }

每次next()都会执行到下一个yield,然后暂停。yield后面的值会作为value返回。等所有代码执行完,done就变成true

二、yield是“暂停键”,next是“播放键”

这个机制有点像你写文章写到一半,突然想喝杯咖啡。你把光标停在某个位置(yield),喝完咖啡回来,再敲一下键盘(next),继续往下写。

更神奇的是,next()还可以传参,这个参数会成为上一个yield的返回值。这就像你暂停时给函数塞了张纸条,告诉它接下来该怎么走。

function* talkGenerator() {
  const name = yield '你叫什么名字?';
  const age = yield `${name},你多大了?`;
  return `${name}今年${age}岁`;
}

const talk = talkGenerator();

console.log(talk.next());        // { value: '你叫什么名字?', done: false }
console.log(talk.next('张三'));   // { value: '张三,你多大了?', done: false }
console.log(talk.next(18));      // { value: '张三今年18岁', done: true }

看到没?第一次next()只是启动,第二次next('张三')把“张三”传给了name,第三次传年龄。这就是Generator的“对话”能力。

三、协程:Generator的底层哲学

Generator函数的这种“暂停/恢复”能力,其实是**协程(Coroutine)**思想的体现。协程是一种比线程更轻量级的并发单元,它可以在多个任务之间主动让出控制权。

在JavaScript里,Generator就是协程的一种实现。你可以用它来模拟多任务协作,比如交替执行两个任务:

function* task1() {
  yield '任务1: 第1步';
  yield '任务1: 第2步';
  return '任务1完成';
}

function* task2() {
  yield '任务2: 第1步';
  yield '任务2: 第2步';
  return '任务2完成';
}

const t1 = task1();
const t2 = task2();

console.log(t1.next().value); // 任务1: 第1步
console.log(t2.next().value); // 任务2: 第1步
console.log(t1.next().value); // 任务1: 第2步
console.log(t2.next().value); // 任务2: 第2步

这样两个任务就像在“交替执行”,但实际还是单线程,只是每次让出控制权。这就是“协作式多任务”。

四、Generator的“主战场”:异步流程控制

在async/await出现之前,Generator是处理异步的利器。比如你要按顺序发起三个网络请求,用Promise可以这么写:

function fetchUser() { return fetch('/user').then(r => r.json()); }
function fetchOrders(userId) { return fetch(`/orders?userId=${userId}`).then(r => r.json()); }
function fetchProducts(orderId) { return fetch(`/products?orderId=${orderId}`).then(r => r.json()); }

// 用Generator + 自动执行器
function* fetchFlow() {
  const user = yield fetchUser();
  const orders = yield fetchOrders(user.id);
  const products = yield fetchProducts(orders[0].id);
  return products;
}

// 需要一个自动执行器,让yield后面的Promise自动执行
function run(generator) {
  const gen = generator();
  function step(result) {
    if (result.done) return result.value;
    return result.value.then(
      res => step(gen.next(res)),
      err => step(gen.throw(err))
    );
  }
  return step(gen.next());
}

run(fetchFlow).then(products => console.log(products));

这个run函数就是传说中的自动执行器,它不断调用next,把Promise的结果传回去。这其实就是async/await的前身——用Generator模拟同步写法。

后来ES7直接把这种模式内置成了async/await,所以现在我们很少直接写Generator了,但它的思想深深影响了现代JS。

五、Generator的实用场景:不仅仅是异步

虽然有了async/await,Generator并没有被淘汰,它还在一些地方发光发热:

1. 无限数据结构

用Generator可以生成无限序列,比如斐波那契数列:

function* fibonacci() {
  let a = 0, b = 1;
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();
console.log(fib.next().value); // 0
console.log(fib.next().value); // 1
console.log(fib.next().value); // 1
console.log(fib.next().value); // 2
// 可以无限取下去

2. 状态机

Generator可以很方便地实现状态机,每个yield代表一个状态:

function* stateMachine() {
  let state = 'idle';
  while (true) {
    const action = yield state;
    switch (state) {
      case 'idle':
        if (action === 'start') state = 'running';
        break;
      case 'running':
        if (action === 'pause') state = 'paused';
        else if (action === 'stop') state = 'idle';
        break;
      case 'paused':
        if (action === 'resume') state = 'running';
        else if (action === 'stop') state = 'idle';
        break;
    }
  }
}

const sm = stateMachine();
console.log(sm.next().value); // idle
console.log(sm.next('start').value); // running
console.log(sm.next('pause').value); // paused
console.log(sm.next('resume').value); // running

3. 简化迭代器

如果一个对象需要实现[Symbol.iterator],用Generator可以省掉很多模板代码:

const myIterable = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  }
};

for (const x of myIterable) {
  console.log(x); // 1,2,3
}

六、Generator vs async/await

既然async/await已经这么方便,为什么还要学Generator?

  • async/await:专注于异步,语法简洁,是处理异步任务的终极形态。
  • Generator:更底层,更灵活,可以暂停任何操作(不仅仅是Promise),还可以用于创建迭代器、状态机等。

async/await本质上就是Generator + 自动执行器的语法糖。所以理解Generator,就能更深刻理解async/await的运作原理。

七、总结:Generator是JS里的“时间胶囊”

Generator函数让我们能够:

  • 暂停函数执行,等以后再继续
  • 通过next传值,实现双向通信
  • yield实现惰性求值和无限序列
  • 模拟协程,实现协作式多任务
  • 为async/await打下基础

虽然现在很少直接写Generator做异步了,但它的思想无处不在。当你用for...of遍历数组时,背后有迭代器;当你用async/await时,底层有Generator的影子。

下次面试官问你“Generator有什么用”,你可以告诉他:这是JavaScript的“时间管理大师”,既能暂停时间,又能穿越时空,还能让异步代码看起来像同步。

明天我们将进入迭代器与可迭代对象,看看for...of、扩展运算符这些语法糖背后,到底藏着什么秘密。如果你觉得这篇文章够有趣,点个赞让更多人看到。我们明天见!

LangChain 教程 03|快速开始:10 分钟创建第一个 Agent

LangChain 教程 03|快速开始:10 分钟创建第一个 Agent

📖 本篇导读:这是 LangChain 系列教程的第 3 篇。本篇将带你用 10 行代码创建第一个智能 Agent,体验 LangChain 的核心魅力。读完预计需要 10 分钟。


简单来说

快速开始只需 5 步:定义工具 → 创建 Agent → 配置参数 → 运行测试 → 扩展功能。

就像做一道菜:准备食材(工具)→ 点火(创建 Agent)→ 调味(配置)→ 翻炒(运行)→ 摆盘(扩展)。


🎯 本节目标

读完本节,你将能够回答这些问题:

  • ❓ 如何用 10 行代码创建一个会查天气的 Agent?
  • ❓ 系统提示(System Prompt)有什么用?如何写一个好的系统提示?
  • ❓ 什么是结构化输出?为什么要用它?
  • ❓ 如何让 Agent 记住之前的对话?
  • ❓ 真实世界的 Agent 需要哪些组件?

核心痛点与解决方案

痛点:AI 开发的"起步困难症"

痛点 传统做法 有多痛苦
不知从何开始 面对一堆文档,无从下手 看了一天文档,一行代码没写
功能太简单 只能调用模型,不会用工具 说是 AI 助手,其实就是个聊天机器人
难以扩展 想加个功能,要重写一半代码 越写越复杂,最后成了"代码屎山"
没有记忆 聊完就忘,无法持续对话 用户:"我刚才问什么来着?"

传统做法 vs LangChain 效率对比

举个例子: 你想做一个能查天气的 AI 助手。

传统做法:

1. 注册天气 API 账号
2. 写天气 API 调用代码
3. 写 OpenAI 调用代码
4. 写逻辑:用户问天气就调用天气 API
5. 测试、调试、修复 bug
6. 想加记忆功能?重写一半代码

解决:LangChain 一键生成

import { createAgent, tool } from "langchain";
import * as z from "zod";

// 1. 定义天气工具
const getWeather = tool(
  (input) => `It's always sunny in ${input.city}!`,
  {
    name: "get_weather",
    description: "Get the weather for a given city",
    schema: z.object({ city: z.string() }),
  }
);

// 2. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  tools: [getWeather],
});

// 3. 运行测试
const result = await agent.invoke({
  messages: [{ role: "user", content: "东京天气怎么样?" }],
});

console.log(result.messages.at(-1)?.content);
// Output: It's always sunny in Tokyo!

效果对比:

指标 传统做法 LangChain
代码量 50+ 行 10+ 行
开发时间 半天 10 分钟
功能完整度 基础 完整(工具 + 推理 + 记忆)
可扩展性 好(加工具就行)

生活化类比:创建 Agent 就像开咖啡店

创建 Agent 就像开咖啡店

步骤 类比 LangChain 对应
准备工具 咖啡机、磨豆机、冰箱 tool() 定义工具
设定规则 咖啡店规则("微笑服务") systemPrompt 设定行为
配置原料 咖啡豆、牛奶、糖 model 配置模型
记住常客 会员卡、偏好记录 checkpointer 添加记忆
规范输出 统一杯型、标签 responseFormat 结构化输出
开始营业 迎接客人 invoke() 运行 Agent

步骤一:创建基础 Agent(10 行代码)

创建基础 Agent 流程

完整代码

import { createAgent, tool } from "langchain";
import * as z from "zod";

// 1. 定义天气工具
const getWeather = tool(
  (input) => `It's always sunny in ${input.city}!`,
  {
    name: "get_weather",
    description: "Get the weather for a given city",
    schema: z.object({ city: z.string() }),
  }
);

// 2. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  tools: [getWeather],
});

// 3. 运行测试
const result = await agent.invoke({
  messages: [{ role: "user", content: "东京天气怎么样?" }],
});

// 4. 查看结果
console.log(result.messages.at(-1)?.content);
// Output: It's always sunny in Tokyo!

代码解析

行号 代码 人话解读
5-14 tool() 定义 "我创建了一个叫 get_weather 的工具,能查指定城市的天气"
6 工具逻辑 "工具被调用时,返回一个固定的天气信息"
8-12 工具配置 "告诉 Agent:这个工具叫什么、能做什么、需要什么参数"
17-20 createAgent() "创建一个 AI 助手,用 Claude 模型,会使用天气工具"
23-26 invoke() "启动任务:用户问东京天气,Agent 会自己决定调用什么工具"
29 查看结果 "从返回的消息中找到最后一条,那是 Agent 的回答"

💡 人话解读

  • tool() 函数就像"注册一个技能",告诉 Agent 它会什么
  • createAgent() 就像"雇佣一个员工",给他技能和大脑
  • invoke() 就像"给员工派任务",他会自己想办法完成

步骤二:创建真实世界的 Agent

真实世界 Agent 架构

真实世界的 Agent 需要什么?

组件 作用 为什么需要
系统提示 设定角色和行为 让 Agent 知道自己是谁,该怎么说话
多个工具 扩展能力 一个工具不够用,需要多个工具配合
模型配置 控制输出 调整温度、超时等参数,让输出更稳定
结构化输出 格式统一 让 Agent 返回固定格式的数据,方便后续处理
记忆 持续对话 记住之前的对话,像人类一样聊天

完整示例:天气预报助手(会说双关语)

import { createAgent, tool } from "langchain";
import { MemorySaver } from "@langchain/langgraph";
import * as z from "zod";

// 1. 定义系统提示
const systemPrompt = `You are an expert weather forecaster, who speaks in puns.

You have access to two tools:

- get_weather_for_location: use this to get the weather for a specific location
- get_user_location: use this to get the user's location

If a user asks you for the weather, make sure you know the location. 
If you can tell from the question that they mean wherever they are, 
use the get_user_location tool to find their location.`;

// 2. 定义工具
const getWeather = tool(
  ({ city }) => `It's always sunny in ${city}!`,
  {
    name: "get_weather_for_location",
    description: "Get the weather for a specific location",
    schema: z.object({ city: z.string() }),
  }
);

const getUserLocation = tool(
  (_, config) => {
    const { user_id } = config.context;
    return user_id === "1" ? "Florida" : "SF";
  },
  {
    name: "get_user_location",
    description: "Get the user's current location",
    schema: z.object({}),
  }
);

// 3. 定义结构化输出格式
const responseFormat = z.object({
  punny_response: z.string(),
  weather_conditions: z.string().optional(),
});

// 4. 设置记忆
const checkpointer = new MemorySaver();

// 5. 创建 Agent
const agent = createAgent({
  model: "claude-sonnet-4-5-20250929",
  systemPrompt,
  tools: [getUserLocation, getWeather],
  responseFormat,
  checkpointer,
});

// 6. 运行 Agent
const config = {
  configurable: { thread_id: "1" },
  context: { user_id: "1" },
};

// 第一次提问:问外面的天气
const response1 = await agent.invoke(
  { messages: [{ role: "user", content: "外面天气怎么样?" }] },
  config
);
console.log("First response:", response1.structuredResponse);

// 第二次提问:继续对话
const response2 = await agent.invoke(
  { messages: [{ role: "user", content: "谢谢!" }] },
  config
);
console.log("Second response:", response2.structuredResponse);

预期输出

// 第一次回答
First response: {
  punny_response: "Florida is still having a 'sun-derful' day! The sunshine is playing 'ray-dio' hits all day long!",
  weather_conditions: "It's always sunny in Florida!"
}

// 第二次回答
Second response: {
  punny_response: "You're 'thund-erfully' welcome! It's always a 'breeze' to help you stay 'current' with the weather.",
  weather_conditions: undefined
}

💡 人话解读

  • 系统提示让 Agent 成为"会说双关语的天气预报员"
  • get_user_location 工具让 Agent 知道用户在哪里
  • 结构化输出让 Agent 返回固定格式的数据
  • checkpointer 让 Agent 记住之前的对话

核心组件详解

1. 系统提示(System Prompt)

什么是系统提示? 系统提示是给 Agent 的"身份说明书",告诉它:

  • 你是谁(角色)
  • 你该怎么说话(风格)
  • 你有什么工具(能力)
  • 你该怎么使用工具(规则)

好的系统提示的特点:

特点 示例 为什么重要
具体 "你是会说双关语的天气预报员" 让 Agent 知道自己的定位
可操作 "如果不知道位置,使用 get_user_location 工具" 给 Agent 明确的行动指南
简洁 控制在 100-200 字 避免占用太多上下文空间
个性化 "说话要幽默,多用天气相关的双关语" 让 Agent 有独特的人格

2. 工具(Tools)

工具的结构:

const myTool = tool(
  (input, config) => {
    // 工具逻辑:接收输入,返回结果
    return "工具执行结果";
  },
  {
    name: "tool_name",          // 工具名字
    description: "工具描述",     // Agent 靠这个决定何时使用
    schema: z.object({          // 参数验证
      param1: z.string(),
      param2: z.number(),
    }),
  }
);

工具的参数:

参数 类型 说明 例子
input object 工具的输入参数 { city: "Tokyo" }
config object 上下文信息 { context: { user_id: "1" } }

3. 结构化输出(Response Format)

什么是结构化输出? 让 Agent 返回固定格式的数据,而不是自由文本。

为什么要用?

  • ✅ 格式统一,方便后续处理
  • ✅ 类型安全,减少错误
  • ✅ 前端展示更方便

使用方法:

const responseFormat = z.object({
  name: z.string(),         // 必需字段
  age: z.number().optional(), // 可选字段
  tags: z.array(z.string()), // 数组
});

const agent = createAgent({
  // ...
  responseFormat, // 告诉 Agent 返回这个格式
});

// 使用时
const result = await agent.invoke({/* ... */});
console.log(result.structuredResponse); // 直接得到结构化对象

4. 记忆(Memory)

什么是记忆? 让 Agent 记住之前的对话,保持上下文连续性。

如何使用?

import { MemorySaver } from "@langchain/langgraph";

// 创建记忆存储
const checkpointer = new MemorySaver();

const agent = createAgent({
  // ...
  checkpointer, // 添加记忆
});

// 运行时需要 thread_id
const config = {
  configurable: { thread_id: "conversation_1" }, // 每个对话一个 ID
};

// 第一次对话
await agent.invoke({/* ... */}, config);

// 第二次对话(用同一个 thread_id)
await agent.invoke({/* ... */}, config);

⚠️ 注意MemorySaver 是内存存储,重启后会丢失。生产环境要用持久化存储,比如数据库。


业务场景:不同类型的快速应用

Agent 业务场景应用

场景 工具需求 系统提示 特色功能
客服助手 查询订单、查物流、处理退款 "你是专业客服,语气友好,解决问题"
结构化输出:统一回复格式
个人助手 查天气、定闹钟、发邮件 "你是贴心助手,记住用户偏好" 记忆功能:记住用户习惯
学习助手 搜索资料、解答问题、生成练习 "你是耐心老师,讲解详细,鼓励学生" 多工具协作:搜索 + 总结
营销助手 生成文案、分析数据、找客户 "你是创意营销专家,善于抓痛点" 结构化输出:营销文案模板
代码助手 搜索文档、生成代码、调试错误 "你是资深程序员,代码简洁,注释清晰" 工具集成:查 API 文档

示例:客服助手

工具:

  • query_order:查询订单状态
  • track_shipment:查询物流信息
  • process_refund:处理退款

系统提示:

You are a helpful customer service agent. 
Be friendly and patient. 
Always try to solve the customer's problem. 
If you need order information, use the query_order tool. 
If you need shipping information, use the track_shipment tool. 
If the customer wants a refund, use the process_refund tool.

使用:

const result = await agent.invoke({
  messages: [{ role: "user", content: "我的订单 #12345 发货了吗?" }]
});

常见问题与解决方案

问题 原因 解决方案
Agent 不知道用工具 工具描述不够清晰 写更详细的 description,说明什么时候用
Agent 回答格式不对 没有使用结构化输出 添加 responseFormat
Agent 记不住对话 没有添加记忆 使用 checkpointerthread_id
Agent 说话风格不对 系统提示不够具体 写更详细的系统提示,指定风格
运行速度慢 模型参数设置不当 调整 temperaturetimeout 等参数
API Key 错误 环境变量没配置 检查环境变量是否正确设置

💡 调试技巧

  • 先从简单的工具开始
  • 逐步添加功能
  • console.log 打印中间结果
  • 检查 Agent 的思考过程

总结对比表

功能 基础 Agent 真实世界 Agent 区别
工具数量 1 个 多个 能力更全面
系统提示 详细 行为更规范
模型配置 默认 自定义 输出更稳定
结构化输出 格式更统一
记忆 能持续对话
代码量 10 行 50 行 功能更完整
适用场景 快速测试 生产环境 更专业可靠

核心要点回顾

  1. 快速开始 5 步:定义工具 → 创建 Agent → 配置参数 → 运行测试 → 扩展功能

  2. 10 行代码tool() 定义技能,createAgent() 创建助手,invoke() 启动任务

  3. 系统提示:给 Agent 设定角色、风格和规则,越具体越好

  4. 结构化输出:用 Zod 定义格式,让 Agent 返回固定结构的数据

  5. 记忆功能:用 MemorySaverthread_id 让 Agent 记住对话

  6. 真实世界:多个工具、详细系统提示、自定义模型配置、结构化输出、记忆,这些是生产级 Agent 的标配


记住:快速开始的目的不是写完美的代码,而是快速体验 LangChain 的魅力。

先跑起来,再慢慢优化。你已经迈出了 AI 应用开发的第一步,接下来的路会越来越精彩!🚀

关注「WEB大前端」,每周分享技术实践和行业洞察。

大三面字节被问懵?手撕 WebSocket 与 SSE 底层原理,大厂通关指南

俗话说得好:“面试造火箭,工作拧螺丝”。但如果你连长连接的底层协议都搞不清楚,可能连进大厂拧螺丝的资格都没有。

昨天,隔壁寝室的哥们面字节暑期实习,直接被一道 408 场景题干得汗流浃背: “做过 Chat App 是吧?那你说说 WebSocket 和 SSE 有什么区别?接 DeepSeek 的流式输出该用哪个?”

很多同学平时写业务天天 npm install 调包,遇到网络层的问题直接“阿巴阿巴”。但在这个 AI 大模型全网刷屏的时代,长连接和流式输出早就成了前端和 Node.js 圈的绝对高频考点

作为一名见不得“屎山代码”的大三党,今天学弟就带大家抓个包,把 HTTP 轮询、WebSocket 和 SSE 的底层逻辑扒个底朝天。建议先 ⭐ 收藏,面试前拿出来背一遍,绝对让面试官对你刮目相看!


🤡 为什么说 HTTP 轮询是“外包级”方案?

假设现在需求是做一个在线聊天室。新手最爱干的事,就是写个 setInterval(),每隔 3 秒发个 Ajax 请求去问服务器:“大佬,有新消息吗?”

⚠️ 前方高能:这是典型的史诗级灾难写法! HTTP 是一个无状态、单向的短连接(Request-Response 模型)。你每次轮询,都要重新建立 TCP 连接(即使有 Keep-Alive 也会有巨大开销),还要带上一大堆臃肿的 HTTP Header。

打个通俗的比方:HTTP 就像是**“寄信”**。用轮询做聊天室,就像是你每隔 3 秒就去狂敲邮局的门问:“有我的信吗?”——不仅你累,服务器也得被你烦死,人一多直接原地宕机。


🚀 降维打击:WebSocket 的全双工魔法

为了终结这种愚蠢的轮询,HTML5 推出了 WebSocket 协议。这玩意儿一上来,直接把“寄信”跨时代地升级成了**“打电话”**。只要电话一接通,双方就可以毫无阻碍地互发消息。

Talk is cheap,我们先看一眼用 Koa 撸一个 WebSocket 服务器有多优雅:

JavaScript

const Koa = require('koa'); 
const websocket = require('koa-websocket');

// 注入 WebSocket 能力
const app = websocket(new Koa());
const clients = new Set(); // 维护客户端连接池

// 处理 WebSocket 长连接逻辑
app.ws.use(async (ctx, next) => {
    clients.add(ctx.websocket); // 用户上线
    
    // 服务端接收到消息时,广播给所有人(群聊核心逻辑)
    ctx.websocket.on('message', message => {
        for (const client of clients) {
            client.send(message.toString());   
        }
    })
    
    // 划重点:断开连接时必须清理内存,否则会导致内存泄漏!
    ctx.websocket.on('close', () => {
        clients.delete(ctx.websocket);
    })
})

app.listen(3000);

代码很简单,但面试官真正在意的是下面这两个底层护城河

💀 硬核揭秘 1:抓包看 101 协议升级 的密码学验证

面试官发难:“WebSocket 建立连接时发的是 HTTP 请求吗?”

拔掉网线,打开 Wireshark 或者 Network 面板抓个包,你会发现第一次握手的 Header 里藏着玄机:

HTTP

Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

看到这个 Sec-WebSocket-Key 了吗?服务端收到这串随机的 Base64 字符后,必须做一套极其严格的规范动作:

  1. 把这个 Key 与一个全球通用的魔法字符串258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接。
  2. 进行 SHA-1 运算,再转成 Base64,生成 Sec-WebSocket-Accept 返回给客户端。

为什么要这么折腾?防黑客吗? 错!明文传输防个锤子。这是为了防止无意的**“缓存投毒” (Cache Poisoning)**,并且让客户端确认:“对面这台服务器是真的懂 WebSocket 协议,而不是碰巧返回了 200 OK”。

💀 硬核揭秘 2:为什么 WS 能发图片,而 HTTP 只能发文本?

WebSocket 传输的数据不叫报文,叫**“数据帧(Frame)”**。协议底层定义了一个 4 bit 的 Opcode(操作码)

  • Opcode = 0x1:浏览器知道这是一串文本
  • Opcode = 0x2:浏览器知道这是一坨二进制流,直接扔给 ArrayBuffer 处理图片或音视频。

这才是它能扛起复杂互动场景(如页游、直播弹幕)的全能底气。


🤖 大模型时代的新宠:SSE (Server-Sent Events)

既然 WebSocket 这么强,那为什么我们用 ChatGPT 或 DeepSeek 时,抓包发现它们根本没用 WebSocket,而是用了 SSE

因为业务场景变了! 大模型的“打字机效果”,是一个单向流式输出的过程。你发一句 Prompt,AI 连续吐出几百个词。这个场景根本不需要全双工双向发消息,只需要服务器单向高频推送即可!

💀 硬核揭秘 3:扒掉 SSE 的外衣,它的底层其实是 Chunked 编码

很多小白把 SSE 当成什么高深的新协议,大错特错!SSE 是 100% 纯正的 HTTP/1.1 协议。

它的核心黑科技,是利用了 HTTP 响应头里的 Transfer-Encoding: chunked(分块传输编码)

HTTP

Content-Type: text/event-stream
Transfer-Encoding: chunked
Connection: keep-alive

正常的 HTTP 请求必须带 Content-Length,浏览器拿到指定大小的数据就关门大吉。 但加上 chunked 后,服务器的意思是:“我也不知道 AI 要说多少废话,我一块一块(Chunk)发给你吧。”

服务器每次吐出一个字,就按 data: 你好\n\n 的格式发过去。浏览器底层的流处理器只要读到 \n\n,就知道一块数据到了,立刻触发前端的渲染。杀鸡焉用牛刀,处理单向推送,SSE 才是最优雅的神!


🔥 终极避坑:大厂必问的“心跳保活”机制

不管你用 WS 还是 SSE,只要写了“长连接”,面试官必放终极杀招: “如果用户进了电梯没信号了,或者直接拔了网线,你的服务器怎么知道他掉线了?”

千万别回答“等 TCP 超时断开”——TCP 底层的 Keep-Alive 默认要两小时才触发,那时候你服务器的连接池早被死链接撑爆了!

正确的做法是在应用层实现心跳机制 (Heartbeat)

  • 常规玩法:客户端定时器每隔 30 秒发一个 JSON 格式的 Ping 消息,服务器回复 Pong。超时未收到回复,前端主动断开并重连。
  • 满分玩法(针对 WebSocket) :利用刚才提到的底层帧结构!WebSocket 协议原生定义了 0x9 (Ping帧)0xA (Pong帧)。在 Node.js 中,你可以直接调用底层的 Ping/Pong 控制帧,连 JSON 序列化的性能损耗都省了,把并发性能压榨到极致!

🎯 总结:没有银弹,只有取舍

架构设计的魅力就在于“看菜下饭”:

  1. 联机游戏、协同文档、实时聊天室 👉 毫不犹豫选 WebSocket
  2. 大模型对话、站内单向消息通知 👉 选轻量级、原生兼容 HTTP 的 SSE

技术迭代浩浩荡荡,最后给各位技术大佬留个探讨题:随着 HTTP/2 和 HTTP/3 的普及,它们强大的多路复用和全双工特性,未来会让 WebSocket 退出历史舞台吗?

欢迎在评论区畅所欲言,学弟在线挨打交流!👇

不懂模块化就别谈前端工程化

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

前端工程化最基本的一步就是先学会模块化。简单来说,模块化就是把一大坨代码,拆成一个个小块,每个小块只做一件事,这样写起来和维护都方便多了。而且模块化还能让代码更容易被重复使用,像写好的请求封装、表单验证啥的,以后就不用再重新写一遍。多人合作的时候,模块化能让大家各做各的,互相不踩脚。更重要的是,像 Webpack 这种打包工具,都是基于模块化才能更好工作。常见的模块化写法有 CommonJS、ES Module 这些,学会了它们,工程化就有底子了。掌握模块化,等于给前端工程化打好地基!

什么是模块化

模块化的概念并不是一开始就有的。早期的网页都靠一个个大文件堆在一起,代码混乱又难维护。后来,项目越来越大,大家发现这样不行,得把功能拆分开。于是就有了“模块化”的想法:把代码分成小模块,每个模块只干一件事。这样一来,改东西的时候不容易出错,也能更好地复用代码。模块化也让多人一起开发的时候更有条理,减少冲突。现在常见的模块化方式有 CommonJS、ES Module 这些,都是让代码更清晰、管理更方便。掌握模块化,写项目会省心多了!

模块化的发展历程

石器时代

我们把这个过程称之为石器时代,因为这是最原始阶段,也是 JavaScript 刚被发明的时候(1995 年),它最早是被用来给网页加点动态效果,并没有考虑模块化。这就导致了一个很严重的问题:

  1. 全局变量污染

  2. 难以管理依赖

  3. 代码组织混乱

如下代码所示:

// a.js
const moment = 1;

// b.js
const moment = 2;

在 html 文件中我们有这样的代码来导入它们:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script src="./a.js"></script>
    <script src="./b.js"></script>
  </body>
</html>

很多时候我们会直接在文件里定义变量,无论是自己写的代码、和其他开发成员合作时不同文件里的变量,还是引入的第三方库中的全局变量,都会在全局作用域中共享同一个空间,这种方式在 <script> 标签默认的全局执行环境下非常常见,也因此容易产生变量冲突或被覆盖,导致全局污染和命名冲突,正是因为这样的问题,后续才会有模块化方案来解决作用域隔离和依赖管理的痛点。

20250526193143

这样的问题就非常容易产生了。

IIFE

IIFE(Immediately Invoked Function Expression)的全称是立即执行函数表达式,意思是定义完毕立即执行的函数。它是 JavaScript 中的一种非常常见的语法结构,用来创建一个立即执行的函数作用域,避免污染全局变量。

它的基本语法如下所示:

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

这是借助了函数作用域,创建了一个私有空间(闭包)。在函数里定义的变量、函数,只在这个作用域可见,外部无法访问。

(function () {
  // 这里是局部作用域
  var a = 1;
  console.log(a);
})(); // 立即执行

// 或者
(function () {
  var b = 2;
  console.log(b);
})();

console.log(typeof a);

最终输出结果如下图所示:

20250526195433

通过这种方式,IIFE 可以避免全局污染,并且能把内部变量封装起来,外部无法访问;不过,它不如模块化方案直观易读,在模块化需求较多时,代码结构容易变得混乱。

CommonJs

为了解决 JavaScript 缺少模块化体系的问题,CommonJS 标准被提出了。它主要就是给 JavaScript 提供了一个模块化的规范,让我们可以像在其他语言里那样按需引入、按需导出,把大项目拆成小块再拼装起来。

Node.js 正是借助 CommonJS 的模块体系,才让模块化管理变得井井有条。比如:

// a.js
const moment = require("moment"); // 引入模块

module.exports = { sayHi: () => console.log("hi") }; // 导出模块

这样做,变量和功能都被封装在自己的模块里,不会再跑到全局作用域里去乱七八糟。

AMD

2011 年前后,浏览器端模块化火了,出现了 AMD(代表:RequireJS),它的出现最主要的一个原因就是浏览器端加载文件是异步的,不能再用 CommonJs 的同步方式了。

AMD"Asynchronous Module Definition" 的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

AMD 也采用 require()语句加载模块,但是不同于 CommonJS,它要求两个参数:

require([module], callback);

第一个参数 [module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback,则是加载成功之后的回调函数。如果将前面的代码改写成 AMD 形式,就是下面这样:

require(["math"], function (math) {
  math.add(2, 3);
});

math.add()math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。

接下来编写一个完整的 AMD 来实现这个完整的示例,如下代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <script
      data-main="main"
      src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
    ></script>
  </head>
  <body>
    <h1>AMD 示例页面</h1>
  </body>
</html>

在这里的代码中使用的是 RequireJS CDN,它的关键点是 data-main="main",它告诉 RequireJS:页面加载完后去找 main.js 作为入口。

// math.js
define([], function () {
  // 这是一个模块
  return {
    add: function (a, b) {
      return a + b;
    },
    multiply: function (a, b) {
      return a * b;
    },
  };
});

这里用到了 define(),定义了一个模块,暴露 add 和 multiply 方法。

// main.js
require(["math"], function (math) {
  // 这里 math 就是 math.js 返回的模块对象
  var sum = math.add(3, 4);
  var product = math.multiply(3, 4);

  console.log("3 + 4 =", sum);
  console.log("3 * 4 =", product);

  // 也可以在页面显示
  var resultDiv = document.createElement("div");
  resultDiv.textContent = `3 + 4 = ${sum}, 3 * 4 = ${product}`;
  document.body.appendChild(resultDiv);
});

console.log(111222);

通过使用 require(['math'], callback),浏览器遇到后会异步加载 math.js,加载完毕后再执行回调,在回调里就能拿到 math 模块的内容,进行使用。

最终输出结果如下图所示:

20250528080714

UMD

CommonJS 和 AMD 在各自的领域(服务器端和浏览器端)都很好地解决了模块化问题,但它们之间存在兼容性问题。CommonJS 是同步加载模块的,适合服务器端,因为文件都在本地,加载速度快;而 AMD 是异步加载模块的,适合浏览器端,因为网络请求是异步的。这就导致了一个问题:如何编写一份代码,既能在 Node.js 环境下运行,又能在浏览器环境下运行,同时还能兼容 RequireJS 等 AMD 加载器?

为了解决这个问题,UMD(Universal Module Definition)应运而生。它是一种通用的模块定义规范,旨在创建一个能够兼容 CommonJS、AMD 和全局变量这三种模块化方案的代码模式。它的核心思想是,通过一套条件判断逻辑,检测当前运行环境支持哪种模块化方案,然后以对应的方式来定义和导出模块。这样,开发者就可以编写一份代码,无需修改就能在多种环境下使用。

那什么情况下是需要 UMD 呢?

  1. 跨环境兼容性: 如果你想编写一个 JavaScript 库,既希望它能在 Node.js 项目中使用(通过 CommonJS 模块),也希望它能在浏览器中直接作为 <script> 标签引入(暴露全局变量),同时还能被 RequireJS 等 AMD 加载器识别,那么 UMD 是一个非常理想的选择。

  2. 解决 CommonJS 和 AMD 的冲突: CommonJS 是同步加载的,而 AMD 是异步加载的。直接使用其中一种方案会导致在另一种环境中无法正常工作。UMD 通过判断环境来选择最合适的加载方式。

  3. 简化开发流程: 避免为不同的环境编写多份模块代码,提高代码复用性。

接下来我们将借助 Rollup 来帮我们来实现一个这种 UMD 格式的模块,首先安装所需要的模块:

pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs -D

接下来我们再 src 目录下分别创建一个 index.js 文件和 utils.js 文件,并编写如下代码:

// utils.js
export function add(a, b) {
  return a + b;
}

// index.js
import { add } from "./utils";

export function greet(name) {
  return `Hello, ${name}! The sum is ${add(2, 3)}.`;
}

export function farewell(name) {
  return `Goodbye, ${name}!`;
}

代码编写完成之后我们要在根目录下创建一个 Rollup 配置文件:

// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";

export default {
  input: "src/index.js",
  output: {
    file: "dist/moment.umd.js",
    format: "umd",
    name: "Moment",
    globals: {
      // 如果你的库有外部依赖但不想打包进去,可以在这里配置
      // 'dayjs': 'dayjs' // 例如,如果依赖 dayjs,并且希望从全局变量获取
    },
  },
  plugins: [resolve(), commonjs()],
};

这个时候我们需要在 package.json 中添加一个大包脚本:

  "scripts": {
    "build": "rollup -c"
  },

这个时候我们就可以使用 pnpm build 来执行这些打包了,最终会输出一个 dist 目录:

20250528083501

最终输出的产物如下代码所示:

(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined"
    ? factory(exports)
    : typeof define === "function" && define.amd
    ? define(["exports"], factory)
    : ((global =
        typeof globalThis !== "undefined" ? globalThis : global || self),
      factory((global.Moment = {})));
})(this, function (exports) {
  "use strict";

  function add(a, b) {
    return a + b;
  }

  function greet(name) {
    return `Hello, ${name}! The sum is ${add(2, 3)}.`;
  }

  function farewell(name) {
    return `Goodbye, ${name}!`;
  }

  exports.farewell = farewell;
  exports.greet = greet;
});

上面这个代码片段就是是一个经典的 UMD(Universal Module Definition) 模式构建产物。

它能够检测当前运行环境,并以最合适的方式导出模块:

  1. CommonJS 环境 (如 Node.js):通过 module.exports 导出 farewell 和 greet 函数。

  2. AMD 环境 (如 RequireJS):通过 define(["exports"], factory) 异步定义并导出模块。

  3. 浏览器全局环境 (无模块加载器):将模块内容挂载到全局对象 global.Moment 上。

简而言之,这份代码让我们的 JavaScript 库能够无缝地在 Node.js、支持 AMD 的浏览器以及普通浏览器环境中使用,极大地提高了兼容性。

当我们在 HTML 文件中直接通过 <script src="./dist/moment.umd.js"></script> 引入这份 UMD 文件时,它会检测到当前是浏览器环境,并将模块内容挂载到全局对象 global.Moment 上。你就可以像使用任何全局变量一样使用它:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>umd 示例页面</h1>
    <script src="./dist/moment.umd.js"></script>
    <script>
      console.log(Moment);
    </script>
  </body>
</html>

最终输出结果如下图所示:

20250528084015

尽管 ES Module 已经成为现代 JavaScript 模块化的主流,并在现代浏览器和 Node.js 中得到了原生支持,但 UMD 在向后兼容和跨环境发布库的场景中仍然占有一席之地。理解 UMD 有助于我们更好地理解 JavaScript 模块化的发展历程以及不同模块化方案之间的兼容性问题。

ESM

ES Module,也称为 ECMAScript 模块,是 JavaScript 语言本身在 ES2015 (ES6) 标准中正式引入的官方模块化方案。它旨在成为 JavaScript 模块化的标准,在浏览器和 Node.js 环境中都能原生支持。

与 CommonJS 和 AMD 这种由社区提出的规范不同,ESM 是语言层面的原生支持,这使得它在语法、语义和性能上都具有独特的优势。

深入理解 CommonJS

在 CommonJS 中,每一个被 require 的文件,在 Node.js 内部都会被封装成一个 Module 类的实例。这个 Module 实例携带了该模块的唯一标识(ID)、文件路径、父模块信息、子模块依赖、是否已加载等元数据。

最重要的,它提供了一个 exports 对象,你的模块代码就是通过操作这个对象来决定要向外部暴露什么内容的。当你 require 这个模块时,你得到的就是这个 Module 实例的 exports 属性。

// 此类继承的是 WeakMap
const moduleParentCache = new SafeWeakMap();

function Module(id = "", parent) {
  this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
  this.path = path.dirname(id); // 文件当前的路径

  /
   * 相当于给构造函数 Module 上添加了一个 exports 为空对象
   * 等同于这样的写法 Module.exports = {};
   */
  setOwnProperty(this, "exports", {});

  // 返回一个弱引用对象,表示调用该模块的模块
  moduleParentCache.set(this, parent);
  updateChildren(parent, this, false);

  this.filename = null; // 模块的文件名,带有绝对路径
  this.loaded = false; // 是否已经被加载过,用作缓存
  this.children = []; // 返回一个数组,表示该模块要用到的其他模块
}

我们编写如下代码:

const foo = 1;

module.exports = { foo };

console.log(module);

当我们通过直接打印 module,终端上会有如下输出:

20250528093757

你看到的这个 module 对象,是 Node.js 在运行你的 index.js 文件时,专门为这个文件创建的一个“档案袋”或者说“容器”。这个档案袋里装着关于你这个文件(模块)的所有重要信息:

  • id: '.': 这就好像你的文件在这个程序里的“身份证号码”。当你是直接运行 node index.js 时,这个 index.js 就是主入口,它的 id 会被标记为 .,表示它是整个程序的“根”。

  • path: '/Users/macmini/Desktop/前端工程化': 这就是你的文件所在的文件夹路径。Node.js 在寻找你 require 的其他模块时,会用到这个路径来确定从哪里开始查找。

  • exports: { foo: 1 }: 这是最重要的!它是一个空盒子。你在这个 index.js 文件里写的所有 module.exports = ... 或者 exports.xxx = ... 的代码,都是在往这个盒子里装东西。当其他文件 require 你的 index.js 时,它们拿到的就是这个 exports 盒子里的内容。

  • filename: '/Users/macmini/Desktop/前端工程化/index.js': 这是你的文件的完整名字和路径,就像你的文件在这个电脑里的完整地址一样。

  • loaded: false: 这个告诉我们你的文件是否已经执行完毕。因为 console.log(module) 这行代码是在文件执行过程中打印的,所以此时模块还没有“加载完成”,还在运行,因此显示 false。等整个文件代码都运行完了,它才会变成 true

  • children: []: 如果你的 index.js 里有 require('其他文件') 的话,那些“其他文件”的 module 对象就会出现在这个数组里,表明你的文件依赖了哪些模块。现在它是空的,说明你的 index.js 没有直接 require 其他文件。

  • paths: [...]: 这是 Node.js 在你 require('第三方库名') (比如 require('lodash')) 时,会去依次查找这些目录来找到 node_modules 文件夹。它从你文件所在的目录开始,逐级向上查找。

  • Symbol(...) 开头的属性: 这些是 Node.js 内部使用的一些特殊标记。例如,kIsMainModule: true 再次强调你的文件是程序的主入口;kIsExecuting: true 则表示你的文件代码正在运行中。这些通常对开发者来说是内部实现细节,但也能帮助我们理解模块的生命周期。

简而言之,这个 module 对象就是 Node.js 对你的文件在模块系统中的“档案”,包含了它的身份信息、当前状态、以及如何与外部世界交互(通过 exports)的关键数据。

之所有会有这样的输出,主要是在 NodeJs 源码 中有这样的实现:

function Module(id = "", parent) {}

/** @type {Record<string, Module>} */

Module._cache = { __proto__: null };

/** @type {Record<string, string>} */

Module._pathCache = { __proto__: null };

/** @type {Record<string, (module: Module, filename: string) => void>} */

Module._extensions = { __proto__: null };

/** @type {string[]} */

let modulePaths = [];

/** @type {string[]} */

Module.globalPaths = [];

let patched = false;

let wrap = function (script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  "(function (exports, require, module, __filename, __dirname) { ",
  "\n});",
];

let wrapperProxy = new Proxy(wrapper, {
  __proto__: null,

  set(target, property, value, receiver) {
    patched = true;

    return ReflectSet(target, property, value, receiver);
  },

  defineProperty(target, property, descriptor) {
    patched = true;

    return ObjectDefineProperty(target, property, descriptor);
  },
});

在上面的代码中, Module._cache 是一个缓存区,存储所有已经加载并执行过的模块实例。当你 require 一个模块时,Node.js 会先检查这个缓存,如果模块已经存在,就直接返回缓存中的实例,避免重复加载和执行,确保模块是单例的。 它存储在 Node.js 进程的全局 JavaScript 堆内存中,作为 Module 这个构造函数(或类)的一个静态属性(Module._cache),这意味着它不属于任何特定的模块实例,而是所有模块共享的一个全局数据结构。

wrap 函数和 wrapper 数组是 CommonJS 模块机制的核心,wrapper 数组包含了两个字符串 (function (exports, require, module, filename, dirname) { 是函数体的开始部分,'\n});' 是函数体的结束部分。

这个封装后的函数就是每个 CommonJS 模块被执行时所处的环境。它为你的模块提供了私有的作用域,并且注入了 exportsrequiremodule__filename__dirname 这些局部变量,这样你在模块里才能直接使用它们,而不会污染全局作用域。

module.exports 和 exports 的关系

我们继续来到这里的代码,这相当于给构造函数 Module 上添加了一个 exports 为空对象,等同于这样的写法 Module.exports = {},我们再来到这个文件代码的后面。

20250528203423

_compile 原型方法上定义了一个 exports 用来保存 Module.exports ,所以这也就是为什么 module.exports === exports 的原因了,实际上是它们共享同一块内存空间。

20250528203750

虽然他们共享的是同一块内存空间,但是最终被导出的是 module.exports 而不是 exports。值得注意的是 CommonJs 导出的是对象的引用,通过 require 之后 可以对其进行修改。

如下代码所示:

// utils.js

const object = {
  moment: "Moment",
};

setTimeout(() => {
  object.moment = "靓仔";
}, 2000);

module.exports = {
  object,
};

// main.js
const bar = require("./utils");

console.log("main.js", bar.object.moment); // main.js Moment

setTimeout(() => {
  console.log("2秒之后输出 ", bar.object.moment); // 2秒之后输出  靓仔
}, 2000);

最终的输出结果如下图所示:

20250528204706

验证了我们前面的说法。

CommonJs 读取的模块的缓存

在 Node.js 中,CommonJS 模块首次被 require() 后,其 module.exports 对象就会被缓存到内存中。这意味着,之后无论程序中何处再次 require() 同一个模块,Node.js 都不会重新加载和执行该模块的代码,而是直接返回缓存中的同一个实例。这种机制确保了模块只加载一次,并作为单例存在于整个应用生命周期中,从而优化了性能并避免了状态混乱。

如下代码所示:

// share.js
console.log("---- share.js 模块正在被加载和执行 ----");

let internalCounter = 0;

function increment() {
  internalCounter++;
}

function getCounter() {
  return internalCounter;
}

// 导出一些内容,包括一个时间戳,用于验证是否是同一个实例
module.exports = {
  increment,
  getCounter,
  loadTimestamp: new Date().toISOString(), // 记录模块被加载的时间
};

console.log("---- share.js 模块执行完毕 ----");

创建第一个使用共享模块的模块 (moduleA.js):

// moduleA.js

console.log("*** moduleA.js 开始执行 ***");

const shared = require("./share"); // 第一次 require share
shared.increment(); // 调用共享模块的方法
shared.increment(); // 再次调用,计数器应该增加到 2

console.log("moduleA.js 访问 share 计数器:", shared.getCounter());
console.log("moduleA.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleA.js 执行结束 ***");

// 导出 shared 模块的引用,方便 main.js 进一步验证
module.exports = { sharedModuleRef: shared };

创建第二个使用共享模块的模块 (moduleB.js):

// moduleB.js

console.log("*** moduleB.js 开始执行 ***");

const shared = require("./share"); // 第二次 require share (预期从缓存读取)
shared.increment(); // 再次调用共享模块的方法,计数器应该增加到 3

console.log("moduleB.js 访问 share 计数器:", shared.getCounter());
console.log("moduleB.js 访问 share 加载时间:", shared.loadTimestamp);

console.log("*** moduleB.js 执行结束 ***");

// 导出 shared 模块的引用
module.exports = { sharedModuleRef: shared };

接下来我们创建一个主入口文件 index.js:

// index.js

console.log("--- index.js 开始执行 ---");

const moduleAExports = require("./moduleA");
const moduleBExports = require("./moduleB");

console.log("\n--- 验证共享模块的实例 ---");

// 验证 moduleA 和 moduleB 得到的 share 引用是否相同
console.log(
  "moduleA.js 和 moduleB.js 获得的 share 是同一个引用:",
  moduleAExports.sharedModuleRef === moduleBExports.sharedModuleRef
);

// 验证最终的计数器值
console.log(
  "最终的共享模块计数器值:",
  moduleAExports.sharedModuleRef.getCounter()
); // 或者 moduleBExports.sharedModuleRef.getCounter()

console.log("--- index.js 执行结束 ---");

20250528210528

在上面的输出结果中 share.js 被多次 require() 但最终只执行了一次,说明的代码 share.js 只在 moduleA.js 第一次 require 它时被执行了,之后无论是 moduleB.js 再次 require 它,还是你后续再进行任何 require 操作,Node.js 都直接从缓存中拿取其导出的结果,不再重复执行模块文件。

还有一个最直接、最明确的证据。=== 运算符用于比较两个变量是否指向内存中的同一个对象。输出为 true 毫不含糊地表明 moduleArequire 到的 share 引用和 moduleBrequire 到的 share 引用,它们指向的是内存中的同一个 JavaScript 对象。

require 查找细节

require(X) 中的 X 指向一个核心模块时,Node.js 会直接返回对应的内置模块,并立即停止后续查找。这些核心模块,如 httpfsurlpathEvents,是用 C/C++ 编写的,因此在性能上表现优异。它们在 Node.js 编译时就被集成到二进制文件中,并在 Node 进程启动时直接加载到内存,无需额外的定位或编译过程,从而实现了极致的加载效率。

20250528211143

X 是一个路径(以 ./..// 开头)时,Node.js 会尝试解析它:

  • 如果 X 指向一个文件夹,Node.js 会依次查找该文件夹下的 index.jsindex.json,最后是 index.node 文件。

  • 如果 X 指向一个文件但没有后缀名,Node.js 则会尝试追加 .js.json.node 后缀来查找对应文件。

而当 X 既不是路径也不是核心模块(即一个裸模块名,如 lodash)时,Node.js 会从当前目录的 node_modules 文件夹开始,逐级向上查找父目录中的 node_modules,直到文件系统根目录。如果遍历所有这些路径后仍未找到该模块,系统将报错提示。

如下代码所示:

console.log(module.paths);

20250528211350

它会一层一层网上查找,如果没有查到,会报没有找到的错误:

20250528211516

有了路径之后,下面就是 Module.findPath() 的源码,用来确定哪个是正确的路径,其中以下代码有省略的:

Module._findPath = function (request, paths, isMain) {
  // 如果是绝对路径,则不在搜索,返回空
  const absoluteRequest = path.isAbsolute(request);
  if (absoluteRequest) {
    paths = [""];
  } else if (!paths || paths.length === 0) {
    return false;
  }

  // 第一步:如果当前路径已在缓存中,就直接返回缓存
  const cacheKey = request + "\x00" + ArrayPrototypeJoin(paths, "\x00");
  const entry = Module._pathCache[cacheKey];
  if (entry) return entry;

  let exts;
  // 是否有后缀的目录斜杠
  const trailingSlash = "..."; //省略了很多代码
  // 是否相对路径
  const isRelative = "..."; // 省略了很多代码
  let insidePath = true;
  if (isRelative) {
    const normalizedRequest = path.normalize(request);
    if (StringPrototypeStartsWith(normalizedRequest, "..")) {
      insidePath = false;
    }
  }

  // 遍历所有路径
  for (let i = 0; i < paths.length; i++) {
    const curPath = paths[i];
    if (insidePath && curPath && _stat(curPath) < 1) continue;

    if (!absoluteRequest) {
      const exportsResolved = resolveExports(curPath, request);
      if (exportsResolved) return exportsResolved;
    }

    const basePath = path.resolve(curPath, request);
    let filename;

    const rc = _stat(basePath);
    if (!trailingSlash) {
      if (rc === 0) {
        // File.
        if (!isMain) {
          if (preserveSymlinks) {
            filename = path.resolve(basePath);
          } else {
            filename = toRealPath(basePath);
          }
        } else if (preserveSymlinksMain) {
          filename = path.resolve(basePath);
        } else {
          filename = toRealPath(basePath);
        }
      }

      if (!filename) {
        if (exts === undefined) exts = ObjectKeys(Module._extensions);
        // 该模块文件加上后缀名,是否存在
        filename = tryExtensions(basePath, exts, isMain);
      }
    }

    if (!filename && rc === 1) {
      if (exts === undefined) exts = ObjectKeys(Module._extensions);
      // 目录中是否存在 package.json
      filename = tryPackage(basePath, exts, isMain, request);
    }

    if (filename) {
      // 将找到的文件路径存入返回缓存,然后返回
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  // 如果没有找打返回 false
  return false;
};

我们已经了解了核心模块因 C/C++ 实现而拥有极高的加载速度。然而,为了让这些底层用 C/C++ 编写的内建模块能够无缝地融入 JavaScript 的 CommonJS 模块体系并被 require 函数调用,其内部引入流程却相当复杂。它需要经历多个层面的封装和定义,包括 C/C++ 层的内建模块定义、JavaScript 核心模块的适配和封装,最终才能在 (JavaScript) 文件模块层面被正常引入和使用,以此确保了兼容性和性能的最佳平衡。

20250528212004

整个流程是:用户在 JavaScript 中 require 一个核心模块 -> Node.js 的 JavaScript 层 NativeModule 识别并处理 -> NativeModule 调用 process.binding 进入 C++ 层 -> C++ 层查找并加载对应的预编译模块 -> C++ 模块将其功能以 JavaScript 对象的形式导出,最终返回给用户。这个复杂的分层设计,既保证了核心模块的极致性能,又使其能够无缝融入 Node.js 的 CommonJS 模块加载体系。

一旦 Node.js 确定了模块的准确路径,就可以着手加载它了。你可能会好奇:require 函数究竟从何而来,为何在每个模块中都能“凭空”使用?它背后又执行了哪些操作?

实际上,require 并非一个全局变量。它是 Node.js 在执行每个 CommonJS 模块之前,通过模块封装函数(就是我们之前提到的那个 (function (exports, require, module, __filename, __dirname) { ... });)作为局部参数,注入到该模块的作用域中的。

而这个注入的 require 函数,其核心功能正是来源于 Module 构造函数原型上的 require 方法,它负责执行模块的查找、加载、缓存以及最终返回导出内容的完整流程。

Module.prototype.require = function (id) {
  // 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。
  validateString(id, "id");
  if (id === "") {
    throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
  }
  // 默认为0,表示还没有使用过这个模块,每使用一次便自增一次

  requireDepth++;
  try {
    // 用于检查是否有缓存,有则从缓存里查找
    return Module._load(id, this, /* isMain */ false);
  } finally {
    // 每次结束后递减一个,用于判断递归的层次
    requireDepth--;
  }
};

看完了 require 的了,我们再看看构造函数的静态方法 _load:

Module._load = function (request, parent, isMain) {
  let relResolveCacheIdentifier;
  if (parent) {
    debug('Module._load REQUEST %s parent: %s', request, parent.id);
    relResolveCacheIdentifier = `${parent.path}\x00${request}`;
    // 以文件的绝对地址当成缓存 key
    const filename = relativeResolveCache[relResolveCacheIdentifier];
    reportModuleToWatchMode(filename);
    if (filename !== undefined) {
      // 先通过 key 从缓存中获取模块
      const cachedModule = Module._cache[filename];
      if (cachedModule !== undefined) {
        updateChildren(parent, cachedModule, true);
        if (!cachedModule.loaded)
          // 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键
          return getExportsForCircularRequire(cachedModule);

        // 已经加载好的模块,直接从缓存中读取返回
        return cachedModule.exports;
      }
      // 判断缓存是否存在父模块中,存在则删除
      delete relativeResolveCache[relResolveCacheIdentifier];
    }
  }

  // 判断是否为 node: 前缀的,也就是判断是否为原生模块
  if (StringPrototypeStartsWith(request, 'node:')) {
    // Slice 'node:' prefix
    const id = StringPrototypeSlice(request, 5);

    const module = loadBuiltinModule(id, request);
    if (!module?.canBeRequiredByUsers) {
      throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
    }

    return module.exports;
  }

这个函数的核心逻辑是:它会首先检查请求的模块是否已经存在于内部缓存中——如果已缓存,则直接返回其 exports 对象。如果模块带有 node: 前缀(表明是显式引入的内置模块),则会调用专门的 loadBuiltinModule() 方法处理并返回结果。除此之外,对于所有其他尚未加载过的模块,它会创建一个新的模块实例,执行其代码,并将最终导出的结果保存到缓存中,以供后续快速访问。

CommonJS 通过在检测到循环引用时,立即从缓存中返回模块当前已有的 exports 对象来解决。这意味着,如果一个模块(A)在被 require 时发现它自己又 require 了另一个模块(B)而 B 又 require 了 A,它会立刻提供 A 当前已经导出的部分内容。尽管这个 exports 对象可能是不完整的(缺少尚未执行的代码所导出的属性),但这种机制避免了死锁,并允许模块执行继续进行。

小结

require 的流程图正如下图所示:

20250528213120

Node.js 的 require 模块加载流程包含五个主要阶段。首先是解析(Resolution),确定模块的精确路径;接着是加载(Loading),读取文件内容。然后是包装(Wrapping),将代码放入 CommonJS 函数封装中;随后进行执行(Evaluation),运行模块代码并生成导出内容。最后,模块的导出结果会被缓存(Caching)起来,以确保后续对同一模块的 require 调用能高效地直接获取缓存实例。

CommonJS 模块的加载是同步的,意味着它会阻塞后续代码执行,这在服务器端因文件本地加载速度快而高效,但在浏览器中可能引发阻塞问题。它通过 module.exports 以对象形式导出内容,并且对每个加载的模块都存在缓存,确保无论何时何地 require 同一个模块,都只会得到并操作同一个模块实例。这种缓存机制不仅提升了性能,也有效地处理了模块间的循环引用,避免了死锁。

深入理解 ES Modules

默认情况下,普通的 JavaScript 脚本(包括那些用于旧浏览器兼容的 nomodule 脚本)会阻塞 HTML 解析和页面渲染。为了避免这种阻塞行为,你可以为这些脚本添加 defer 属性。带有 defer 属性的脚本会在 HTML 文档完全解析完毕后才开始执行,并且会按照它们在文档中出现的顺序执行,有效避免了阻塞页面内容呈现。

20250528214610

deferasync 是脚本标签的互斥可选属性,用于控制脚本的加载与执行时机。

对于常规脚本(包括 <script nomodule> 脚本),defer 属性确保脚本在 HTML 解析完成后才按顺序执行,避免阻塞页面渲染;而 async 属性则允许脚本与 HTML 并行解析和下载,并在可用时立即执行,不保证其执行顺序。

至于模块脚本 (<script type="module">),它们的默认行为就类似于 defer,即异步获取并在 HTML 解析后执行。但如果为模块脚本明确指定 async 属性,它及其所有依赖项都将与 HTML 解析并行获取,并一旦可用便立即执行,此时模块的执行顺序不再得到保证。

当我们用 ES Module(import / export)来写前端代码时,JavaScript 引擎在背后会做很多“幕后工作”来帮我们管理这些模块。比如:模块要有自己的作用域(不能全都放到全局变量去乱七八糟),还要能让模块之间互相导入导出,保证变量不会乱改。

这些幕后工作就靠了模块记录(Module Record)和模块环境记录(Module Environment Record)这样的底层概念,它们属于 JavaScript 引擎内部的数据结构,帮我们管理和组织模块。

Module Record

模块记录(Module Record)用来封装一个模块的导入和导出等结构化信息。这些信息在模块链接时非常关键,用来把一个个模块的输入输出都串联起来。一个模块记录里通常包含四个字段:

  1. Realm:用来创建当前模块的作用域。

  2. Environment:模块顶层的绑定环境记录,在模块被链接时设置。

  3. Namespace:模块的命名空间对象,能让外部通过运行时属性访问模块的导出。这个对象本身是“外来对象”,并且没有构造函数。

  4. HostDefined:这个字段是留给宿主环境(host environments)用的,方便在模块中附加额外信息。

Module Environment Record

模块环境记录是 ECMAScript 中的一种特殊的声明性环境记录,用来表示模块的外部作用域。 和普通的作用域环境记录不太一样,它在支持普通变量绑定的同时,还特别提供了不可变的 import 绑定。这些 import 绑定让模块内部能间接访问另一个模块里的变量,但又保证了这些变量不能被修改。

换句话说,不可变绑定就是指模块引入别的模块时,虽然能使用这些导入的变量,但不能在当前模块中直接更改它们,这也是模块化语法的一大特色。

Es Module 的解析流程

在开始之前,我们先大概了解一下整个流程大概是怎么样的,先有一个大概的了解:

  1. 构建(Construction):浏览器根据模块的地址找到对应的 JS 文件,通过网络下载,并把代码解析成一个内部的模块记录(Module Record),为后续步骤做准备。

  2. 实例化(Instantiation):对模块进行实例化,分配内存空间,分析并处理模块里的 import 和 export 语句,让这些变量在内存中有了位置和映射关系。

  3. 执行(Evaluation):真正运行模块里的代码,计算值,并把值写入内存,模块就正式被执行起来了。

Construction 构建阶段

在这个阶段,loader(加载器)负责模块的寻址和下载。它首先从入口文件开始加载,通常在 HTML 中使用 <script type="module"></script> 标签来声明这是一个模块文件。加载器会根据这个入口,去查找并下载模块代码,准备后续的实例化和执行。

20250528215643

模块继续通过 import 语句来声明需要的依赖。在 import 声明中,有一个模块声明标识符(ModuleSpecifier),它告诉 loader 如何去查找下一个模块的地址。

20250528215735

每一个模块标识符都对应着一个模块记录(Module Record),而每个模块记录中包含了:

  • JavaScript 代码本身

  • 执行上下文

  • 以及四种重要的表项:ImportEntriesLocalExportEntriesIndirectExportEntriesStarExportEntries

其中,ImportEntries 是一个 ImportEntry Records 类型的结构,记录了模块里所有的 import 语句信息;

LocalExportEntriesIndirectExportEntriesStarExportEntries 都是 ExportEntry Records 类型的结构,记录了模块的各种导出方式。

ImportEntry Records

一个 ImportEntry Record 记录了当前模块中 import 语句的具体信息,它包含三个字段:

  1. ModuleRequest:模块标识符(ModuleSpecifier),告诉系统从哪里去找这个模块。

  2. ImportName:要从 ModuleRequest 指定的模块中导入的具体名称。值 namespace-object 表示这次导入的是目标模块的命名空间对象。

  3. LocalName:当前模块内部用来引用导入值的变量名,也就是在你自己模块里写的名字。

详情可参考下图:

20250528220153

下面这张表记录了使用 import 导入的 ImportEntry Records 字段的实例:

导入声明 (Import Statement From) 模块标识符 (ModuleRequest) 导入名 (ImportName) 本地名 (LocalName)
import React from "react"; "react" "default" "React"
import * as Moment from "react"; "react" namespace-obj "Moment"
import {useEffect} from "react"; "react" "useEffect" "useEffect"
import {useEffect as effect } from "react"; "react" "useEffect" "effect"
ExportEntry Records

一个 ExportEntry Record 记录了当前模块中的导出信息,它包含四个字段:

  1. ExportName:导出的名称,也就是别的模块在 import 时用到的名字。

  2. ModuleRequest:模块标识符(ModuleSpecifier),如果是间接导出(export { a } from 'x')时,指定从哪里引入。

  3. ImportName:当是间接导出时,要从 ModuleRequest 指定的模块中导出的具体名称。

  4. LocalName:当前模块里要导出的变量名。

ImportEntry Records 不同的是,ExportEntry Records 多了一个 ExportName,专门用来描述这个导出的名字。

下面这张表记录了使用 export 导出的 ExportEntry Records 字段的实例:

导出声明 导出名 模块标识符 导入名 本地名
export var v; "v" null null "v"
export default function f() {} "default" null null "f"
export default function () {} "default" null null "default"
export default 42; "default" null null "default"
export {x}; "x" null null "x"
export {v as x}; "x" null null "v"
export {x} from "mod"; "x" "mod" "x" null
export {v as x} from "mod"; "x" "mod" "v" null
export * from "mod"; null "mod" all-but-default null
export * as ns from "mod"; "ns "mod" all null

回到主题,只有当解析完当前的 Module Record 之后,才能知道当前模块依赖的是那些子模块,然后你需要 resolve 子模块,获取子模块,再解析子模块,不断的循环这个流程 resolving -> fetching -> parsing,结果如下图所示:

20250528220636

这个过程也被称为静态分析,它只会识别 exportimport 关键字,不会真正执行 JavaScript 代码。也正因为这样,import 语句只能出现在全局作用域中,动态导入(import())除外。

那如果多个文件同时依赖同一个模块,会不会引起死循环呢?答案是:不会。

这是因为 loader 使用了一个叫做 Module Map 的东西,来追踪和缓存全局范围内所有的 Module Record。这确保了每个模块只会被 fetch 一次,避免了重复加载或死循环的问题。并且,每个全局作用域都有自己的独立 Module Map。

Module Map 是一个 key/value 结构的映射对象,key 是一个 URL(模块的请求地址),value 是模块类型的字符串(比如 “javascript”)。 模块映射的值可以是模块脚本、null(表示获取失败),或者一个占位符 fetching(表示正在获取中)。

如下图所示:

20250528220855

linking 链接阶段

在所有 Module Record 解析完成后,接下来 JavaScript 引擎会对这些模块进行链接。引擎会从入口文件的 Module Record 开始,按照深度优先的顺序,递归地把依赖的模块链接起来。

在这个过程中,引擎会为每个 Module Record 创建一个 Module Environment Record,用来管理当前模块中声明的变量。

20250528221028

Module Environment Record 中有一个叫做 Binding 的东西,用来存放 Module Record 里导出的变量。比如在模块 main.js 中导出了一个名为 count 的变量,那么在 Module Environment Record 中的 Binding 就会包含一个 count,为这个变量分配内存空间,但初始值是 undefinednull

这个过程类似于 V8 在编译阶段时,先创建一个模块实例对象,并为其中的变量和方法分配内存空间。 当子模块 count.js 中通过 import 关键字导入 main.js 时,count.jsimport 变量和 main.jsexport 变量指向的内存位置是相同的,这样就把父子模块之间的关系联系在一起了。

如下图所示:

20250528221222

需要注意的是,我们称 export 导出的为父模块,import 引入的为子模块,父模块可以对变量进行修改,具有读写权限,而子模块只有读权限。

Evaluation 求值阶段

在所有模块完成链接后,JavaScript 引擎会进入求值阶段。这时,它会按照模块的依赖顺序,执行各个模块文件中的顶层作用域代码。 执行过程中,引擎会将之前在链接阶段中分配好内存空间的变量,赋予实际的运行时值。

这样,模块中声明的变量和导出的内容就真正填充到内存中,整个模块的功能也随之生效。求值阶段也是模块真正开始“工作”的时候,确保模块之间的导入导出关系和依赖都能正确执行。

ES Module 是如何解决循环引用的

在 ES Module 中,模块加载和执行过程通过五种状态来管理,分别是:unlinkedlinkinglinkedevaluatingevaluated

模块的状态存储在 循环模块记录(Cyclic Module Records)的 Status 字段中。通过这个状态,JavaScript 引擎可以判断一个模块是否已经被执行过,从而确保每个模块只会被执行一次。

这也是为什么引擎会使用 Module Map 来缓存全局的 Module Record,保证只在第一次加载时 fetch 并执行一次。

如果检测到一个模块的状态已经是 evaluated,下次再遇到它就会跳过执行,避免了死循环的发生。ES Module 会使用深度优先的方式遍历整个模块图,逐个执行模块的顶层代码,并且只会执行一次,从根本上避免了重复加载和死循环的问题。

深度优先搜索(Depth-First-Search,DFS)是一种常用的图遍历算法,它会尽可能深地搜索一个分支的节点,直到该分支的所有节点都被访问过,再回退到上一层继续探索其他分支。通过这种方式,ES Module 确保了每个模块都能被访问到一次,并且不会重复执行。

20250528221954

来看下面这个循环引用的例子,三个模块之间互相引用,但都只会执行一次:

// main.js
import { bar } from "./bar.js";
export const main = "main";
console.log("main");

// foo.js
import { main } from "./main.js";
export const foo = "foo";
console.log("foo");

// bar.js
import { foo } from "./foo.js";
export const bar = "bar";
console.log("bar");

在 Node.js 中运行 main.js,会得到下面的结果:

20250528221921

可以看到,每个模块只会输出一次,即使循环依赖也不会导致死循环。

总结

前端模块化是将大型代码拆分成独立小块的开发方式,每个模块专注单一功能,提高了代码的可维护性和复用性。模块化经历了从石器时代的全局变量污染,到 IIFE 函数作用域隔离,再到 CommonJS、AMD、UMD 等规范的发展历程。CommonJS 采用同步加载适合服务器端,通过 require/module.exports 实现模块导入导出并具有缓存机制;而 ES Module 是 JavaScript 官方标准,采用异步加载和静态分析,通过 import/export 语法提供更好的性能和树摇优化。掌握模块化是前端工程化的基础,为后续使用 Webpack 等构建工具奠定了重要基础。

手把手搭一套前端监控采集 SDK

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

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

名字会影响记忆和传播。这里把 SDK 叫做"四维",英文 four-dimension,简写 FD,寓意尽量用上帝视角看清页面里发生的事。下文用 TypeScript 写示例,便于类型即文档。

自研采集层还要提前想好几条边界:是否采集可能含个人信息的字段、是否对错误栈与 URL 做脱敏、是否在低端机做采样。这些决定往往比多写一个 observer 更影响能不能上线。

整体结构

采集侧可以拆成四件事:配置、缓存与上报策略、各类 observer 与事件钩子、统一入口类。数据流与模块边界可以对照下图来记,和下面 Mermaid 图表达的是同一条主线。

如下图所示。

20260325075816

从页面事件到内存队列,再到空闲或离开时发往服务端的一整条链路。

20260325080415

配置与入口类

业务侧只需要改上报地址、应用标识等。配置对象建议可合并覆盖,避免散落魔法字符串。可预留 releaseenvironment 字段,方便和后端版本聚类对齐。userId 若涉及合规,建议只传哈希后的业务 id,或默认不传,由登录域自行下发自洽标识。

config.ts 中集中维护默认值,并导出 setConfig,便于在业务入口覆盖:

export interface MonitorConfig {
  reportUrl: string;
  appId: string;
  userId?: string;
  projectName?: string;
  release?: string;
  environment?: "development" | "staging" | "production";
  sampleRate?: number;
}

const config: MonitorConfig = {
  reportUrl: "http://localhost:8000/report",
  appId: "fd-example",
  projectName: "fd-example",
  environment: "development",
  sampleRate: 1,
};

export function setConfig(partial: Partial<MonitorConfig>): void {
  Object.assign(config, partial);
}

export function getConfig(): Readonly<MonitorConfig> {
  return config;
}

FourDimension 负责在构造时拉起各模块。初始化不要依赖构造参数时,可以保持无参构造,只在 init 里注册监听,避免重复调用时重复挂钩子。

import { initPerformance } from "./performance";
import { initBehavior } from "./behavior";
import { initError } from "./error";

export class FourDimension {
  private inited = false;

  init(): void {
    if (this.inited) return;
    this.inited = true;
    initPerformance();
    initError();
    initBehavior();
  }
}

业务里建议异步加载 SDK 脚本,初始化时 new FourDimension().init() 即可。若脚本可能被多次执行,务必保留类似 inited 的幂等守卫,否则 fetch 会被包一层又一层。

上报通道 sendBeacon、图片打点与 XHR

navigator.sendBeacon 适合监控:异步、不抢主线程、在页面卸载时仍有机会发出。注意它发的是 POST,适合带 Blob 指定 Content-Type,而不是假设服务端只收 GET 查询串。

限制也要心里有数:无响应体、旧环境可能不存在、单次 payload 有实际上限(常见讨论量级在数十 KB,宜压 body 体积)。实践里常见优先级是 sendBeacon 优先,其次 1x1 图片 GET(数据需压缩且控制长度),再次带 keepalive: truefetchXMLHttpRequestsendBeacon 返回 false 说明浏览器拒绝排队,应立刻换通道。

下面封装一个带降级的 sendReportsendBeacon 分支用 BlobJSON,图片分支再把数据塞进查询参数(注意浏览器对 URL 长度的限制)。

export function isSupportSendBeacon(): boolean {
  return (
    typeof navigator !== "undefined" &&
    typeof navigator.sendBeacon === "function"
  );
}

export function reportImage(url: string, payload: unknown): void {
  const qs = encodeURIComponent(JSON.stringify(payload));
  const img = new Image();
  img.src = `${url}?reportData=${qs}`;
}

export function reportWithXhr(url: string, body: string): void {
  const xhr = new XMLHttpRequest();
  xhr.open("POST", url);
  xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
  xhr.send(body);
}

export function sendReport(url: string, body: string): void {
  if (isSupportSendBeacon()) {
    const blob = new Blob([body], { type: "application/json" });
    const ok = navigator.sendBeacon(url, blob);
    if (ok) return;
  }
  reportImage(url, JSON.parse(body) as unknown);
}

真实项目里可以在 sendBeacon 返回 false 时再尝试 XHR,把失败样本写入 sessionStorage 下次补发。接收端要核实:网关是否允许 Content-Type: application/jsonPOST,是否对 OPTIONS 预检放行,否则 beacon 在跨域场景会静默失败,需在 Network 面板核对状态码。

上报降级顺序若画成一张小抄,方便和运维对口径。

如下图所示。

20260325075931

三种通道的优先顺序与跨域核对点。

缓存与上报时机

目标是对主线程影响尽量小。常见组合是:

  • 内存里先攒一批,再批量上报
  • requestIdleCallback 在空闲时 flush,不支持时用 setTimeout 兜底
  • 页面离开时把剩余队列一次性发出

离开页面时优先依赖 pagehidevisibilitychange,比单纯 beforeunload 更稳,尤其在移动端后台化场景。visibilitychange 在标签隐藏时就能先 flush 一轮,pagehide 在真正离开时再做最后一跳。两个事件都可能触发 flush 时,要么在 flushQueue 内做"空队列直接返回",要么加发送中锁,避免重复上报同一批。

bfcache 恢复的页面会再走 pageshowpersistedtrue 时会话可能延续,停留时长统计要把可见时间分段累加,不能假设一次进页到一次离开。

type ReportPayload = Record<string, unknown>;

const queue: ReportPayload[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;

export function enqueue(payload: ReportPayload): void {
  queue.push(payload);
}

export function flushQueue(reportUrl: string, immediate = false): void {
  if (!queue.length) return;
  const batch = queue.splice(0, queue.length);
  const body = JSON.stringify({ batch });
  if (immediate) {
    sendReport(reportUrl, body);
    return;
  }
  const run = () => sendReport(reportUrl, body);
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(run, { timeout: 3000 });
  } else {
    setTimeout(run, 0);
  }
}

export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushQueue(reportUrl, false);
  }, delayMs);
}

export function bindLifecycleFlush(reportUrl: string): void {
  const onHide = () => {
    if (document.visibilityState === "hidden") {
      flushQueue(reportUrl, true);
    }
  };
  window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
  document.addEventListener("visibilitychange", onHide);
}

getCache 若要对调用方返回快照,需要深拷贝避免外部改数组。深拷贝实现注意处理循环引用以外的普通 JSON 友好结构即可。

性能指标用最新采集思路

PerformanceObserver 仍是采集绘制与布局类指标的主力,buffered: true 让你晚注入脚本也能拿到已经发生过的条目。导航类指标优先读 PerformanceNavigationTiming,比自己在事件里 performance.now() 更贴近浏览器统计。

在挂 observer 之前可以用静态方法探测当前环境到底支持哪些 entryTypes,避免 observe 直接抛错。下面是一段可放进工具模块的探测逻辑。

export function supportedPerfTypes(): string[] {
  if (typeof PerformanceObserver !== "function") return [];
  return PerformanceObserver.supportedEntryTypes ?? [];
}

export function canObserve(type: string): boolean {
  return supportedPerfTypes().includes(type);
}

Chrome DevToolsPerformanceLighthouse 里跑一遍同页,把面板里的 LCPCLS 与 SDK 打上去的值对比,数量级应一致。若差一个数量级,先查是否重复统计、是否在 iframe 里采集、是否混用了导航时间与绘制时间。

Core Web Vitals 对齐

截至 Google 面向站长的公开说明,Core Web Vitals 核心指标是 LCPINPCLSFID 已被 INP 取代,自研 SDK 仍可同时上报 FID 做历史对比,但产品解读应以 INP 为主。

指标 含义 推荐采集方式
LCP 视口内最大内容绘制完成时刻 PerformanceObservertype: 'largest-contentful-paint',通常取最后一次有效条目
INP 交互到下一帧绘制的延迟分布 PerformanceObservertype: 'interaction'(需较新 Chromium),或引入 web-vitals
CLS 累计布局偏移 PerformanceObservertype: 'layout-shift',且只统计 hadRecentInput === false 的条目并累加 value

FPFCP 仍可通过 type: 'paint' 观察,用于诊断首屏是否"空刷背景"与"首现有意义内容"的差异。

三个核心指标与采集入口的关系,适合印在团队 wiki 首页当速查图。

如下图所示。

20260325080035

LCPINPCLS 与对应 observer 类型名称的对应关系。

paint 与首屏绘制

下面示例合并监听 first-paintfirst-contentful-paint,并在拿到 FCP 后断开,避免重复回调。若你希望两种 paint 都上报,应在两种都见到后再 disconnect,或干脆不断开、由服务端按 paintName 去重。

import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";

function safeObserverSupported(): boolean {
  return typeof PerformanceObserver !== "undefined";
}

export function observePaint(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (
        entry.name !== "first-paint" &&
        entry.name !== "first-contentful-paint"
      )
        continue;
      const json = entry.toJSON();
      enqueue({
        type: "performance",
        subType: "paint",
        paintName: entry.name,
        startTime: json.startTime,
        pageURL: location.href,
      });
      if (entry.name === "first-contentful-paint") {
        obs.disconnect();
        scheduleFlush(getConfig().reportUrl);
        break;
      }
    }
  });
  obs.observe({ type: "paint", buffered: true });
}

LCP 在页面生命周期内可能更新,规范语义是"最后一个汇报的 LCP 条目代表当前候选"。简单实现可以在回调里每次都上报最新一条,由服务端取同会话最后一次,或在客户端只保留最大 startTime 的那条再上报。注意 LCP 回调触发时 entry.element 可能已被移除,DOM 引用要谨慎,上报 tagName 与资源 URL 即可。

export function observeLcp(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries() as PerformanceEntry[];
    const last = entries[entries.length - 1] as LargestContentfulPaint &
      PerformanceEntry;
    const json = last.toJSON();
    enqueue({
      type: "performance",
      subType: "lcp",
      startTime: json.startTime,
      element: last.element?.tagName,
      url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "largest-contentful-paint", buffered: true });
}

上面用到 LargestContentfulPaint 时,若项目 lib.dom 较旧,可把 last 标成 PerformanceEntry 并谨慎读取可选字段。

CLSINP

CLS 需要过滤用户操作附近的偏移,避免把有意交互造成的布局变化算成体验问题。

export function observeCls(): void {
  if (!safeObserverSupported()) return;
  let clsScore = 0;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceEntry[]) {
      const ls = entry as LayoutShift & {
        hadRecentInput?: boolean;
        value?: number;
      };
      if (ls.hadRecentInput) continue;
      clsScore += ls.value ?? 0;
      enqueue({
        type: "performance",
        subType: "cls",
        value: ls.value,
        cumulativeLayoutShift: clsScore,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "layout-shift", buffered: true });
}

INP 依赖 type: 'interaction'PerformanceObserver,浏览器支持面仍在演进。生产环境若要省心,可直接使用 web-vitals 包,它会在不支持时降级或给出兼容策略。最小接入示意如下,真实项目里把 console.log 换成 enqueue 即可。

import { onINP } from "web-vitals";

onINP((metric) => {
  const v = metric.value;
  console.log("INP ms", v);
});

自研最小实现可以封装为"支持则订阅,不支持则不上报",避免把未定义行为写死进业务。

导航时间与 DOMContentLoadedload

更稳的做法是读取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相对 fetchStartstartTime 的各阶段时刻算 DNSTCPTTFBDOM 解析等。字段含义以 MDN 上的 PerformanceNavigationTiming 为准,换公式前用一次 console.tablenav 打出来核对。

export function collectNavigationTiming(): void {
  const [nav] = performance.getEntriesByType(
    "navigation",
  ) as PerformanceNavigationTiming[];
  if (!nav) return;
  enqueue({
    type: "performance",
    subType: "navigation",
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
    load: nav.loadEventEnd - nav.fetchStart,
    pageURL: location.href,
  });
  scheduleFlush(getConfig().reportUrl);
}

可在 load 事件触发后再调用一次,确保 loadEventEnd 已非 0。单页应用在客户端路由切换时不会产生新的 navigation 条目,若要监控"软导航",需要结合框架路由钩子或 Performance API 里仍在演进的软导航相关能力单独设计,不能把 PV 和导航耗时混在一条 navigation 记录里硬解释。

资源耗时

资源条目用 type: 'resource'。注意不要在每个 entry 上都 disconnect,否则只会收到第一条资源。更合理的是页面 load 后一次性读取 performance.getEntriesByType('resource'),或长期观察但在 disconnect 前处理完整批次。

跨域资源若没有正确的 Timing-Allow-Origin,多数细粒度时长在浏览器里会被抹成 0,这是安全策略不是 SDK 坏了。核实方式是对比同源静态资源与 CDN 资源的 transferSizedomainLookupStart 等是否突然全 0。

export function observeResources(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
      enqueue({
        type: "performance",
        subType: "resource",
        name: entry.name,
        initiatorType: entry.initiatorType,
        duration: entry.duration,
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        protocol: entry.nextHopProtocol,
        transferSize: entry.transferSize,
        encodedBodySize: entry.encodedBodySize,
        decodedBodySize: entry.decodedBodySize,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "resource", buffered: true });
}

若担心资源量过大,可在客户端按域名白名单或按耗时阈值过滤后再入队。也可按 config.sampleRate 随机丢弃非错误样本,只保留长尾。

接口耗时:fetchXHR

只劫持 XMLHttpRequest 会漏掉现代代码里大量的 fetch。可以同时包装 window.fetchXMLHttpRequest.prototype。包装 fetch 时不要假设调用方不克隆 Response 去读体,监控侧只读 status 与头即可,避免和消费方抢读同一个 body 流。

export function patchFetch(): void {
  const orig = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const start = performance.now();
    const req = input instanceof Request ? input : new Request(input, init);
    try {
      const res = await orig(req);
      const end = performance.now();
      enqueue({
        type: "performance",
        subType: "fetch",
        url: req.url,
        method: req.method,
        status: res.status,
        duration: end - start,
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      return res;
    } catch (err) {
      const end = performance.now();
      enqueue({
        type: "error",
        subType: "fetch",
        url: req.url,
        method: req.method,
        duration: end - start,
        message: err instanceof Error ? err.message : String(err),
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      throw err;
    }
  };
}

XHR 劫持仍可用 opensend 包装,在 loadend 上打点时间戳,与上文思路一致,此处不重复贴全。

错误上报

资源错误与 JS 运行时错误要分开通道。window.addEventListener('error', …, true) 在捕获阶段能拿到 scriptlinkimg 等加载失败,event.target 指向元素。纯 JS 语法与运行时错误同一事件里 target 往往为空,可配合 window.onerror 或同一监听里分支处理。ErrorEvent 上的 message 在跨域脚本且未正确配置 crossorigin 时可能是统一口令,需要和源站 CORS 配置一起核实。

Promise 未处理拒绝用 unhandledrejection。上报体里尽量带 reason 的栈信息,字符串化时注意大对象。

事件路径不要用已弃用的 event.path,改用 event.composedPath()

错误从页面钻进队列前,按类型分流,便于后端路由到不同看板。

如下图所示。

20260325080152

资源、脚本、Promise 三类错误进入同一条上报管道前的分流意象。

function elementPath(ev: Event): string[] {
  const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
  return path
    .filter((n): n is Element => n instanceof Element)
    .map((el) => el.tagName);
}

export function initGlobalErrorHandlers(): void {
  window.addEventListener(
    "error",
    (ev) => {
      const t = ev.target;
      if (
        t &&
        t instanceof HTMLElement &&
        (t instanceof HTMLImageElement ||
          t instanceof HTMLScriptElement ||
          t instanceof HTMLLinkElement)
      ) {
        const url =
          "src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
        enqueue({
          type: "error",
          subType: "resource",
          url,
          tag: t.tagName,
          paths: elementPath(ev),
          pageURL: location.href,
        });
        scheduleFlush(getConfig().reportUrl);
        return;
      }
      if (!ev.message) return;
      enqueue({
        type: "error",
        subType: "js",
        message: ev.message,
        filename: ev.filename,
        lineno: ev.lineno,
        colno: ev.colno,
        stack: ev.error instanceof Error ? ev.error.stack : "",
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
    },
    true,
  );

  window.addEventListener("unhandledrejection", (ev) => {
    const reason = ev.reason;
    enqueue({
      type: "error",
      subType: "promise",
      stack: reason instanceof Error ? reason.stack : String(reason),
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
}

若担心第三方脚本堆栈污染,可在入口做采样或域名过滤。生产环境应上传 source map 到私有桶,由服务端按 release 解析栈,而不是把完整文件路径暴露给前端库。

行为数据:PV、停留时长、点击

PV 在每次路由或首屏进入时打一条,带上 document.referrer 与本地生成的会话或设备标识。UV 必须在服务端用 cookie、登录 id 或可信指纹聚合,客户端只能提供匿名 id。单页应用要在路由变化时手动调一次 reportPv,仅依赖首屏加载会严重低估。

停留时长用 visibilitychange 记录可见累计时间,比只在 beforeunload 减一次更准,尤其是后台标签与 bfcache 场景。离开页面时再发一条汇总,字段里带 visibleMs 即可。下面是一段与队列解耦的计时思路,需与上文的 enqueueflushQueuegetConfig 同模块配合使用。

import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";

let visibleAccum = 0;
let lastVisibleStart = performance.now();

document.addEventListener("visibilitychange", () => {
  const now = performance.now();
  if (document.visibilityState === "visible") {
    lastVisibleStart = now;
  } else {
    visibleAccum += now - lastVisibleStart;
  }
});

window.addEventListener("pagehide", () => {
  if (document.visibilityState === "visible") {
    visibleAccum += performance.now() - lastVisibleStart;
  }
  enqueue({
    type: "behavior",
    subType: "dwell",
    visibleMs: Math.round(visibleAccum),
    pageURL: location.href,
  });
  flushQueue(getConfig().reportUrl, true);
});

点击监听建议防抖,避免长按或滑动误触暴风上报。坐标与 outerHTML 体积要限长,防止队列爆炸。敏感页面不要上传完整 outerHTML,可只保留 data- 业务埋点键名。

下面用 sessionStorage 存会话 id,首次访问时用 crypto.randomUUID() 生成。若需兼容极老环境,可再降级到时间戳加长随机串。

function createSessionId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
  sessionId = createSessionId();
  sessionStorage.setItem("fd_sid", sessionId);
}

export function reportPv(): void {
  enqueue({
    type: "behavior",
    subType: "pv",
    pageURL: location.href,
    referrer: document.referrer,
    sessionId,
  });
  scheduleFlush(getConfig().reportUrl);
}

export function reportClickDebounced(delayMs = 500): void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  window.addEventListener("pointerdown", (ev) => {
    if (!(ev.target instanceof Element)) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const el = ev.target;
      const r = el.getBoundingClientRect();
      enqueue({
        type: "behavior",
        subType: "click",
        tag: el.tagName,
        x: r.left,
        y: r.top,
        paths: elementPath(ev),
        pageURL: location.href,
        sessionId,
      });
      scheduleFlush(getConfig().reportUrl);
    }, delayMs);
  });
}

上线前建议核对的一张表

把下面几项当成发布前 checklist,在 Chrome 与一种目标内核(如 Safari 或内置浏览器)各测一遍。

核对项 怎么核实 常见坑
sendBeacon 是否到达 Network 里看 report 请求体与状态码 跨域未放行 POST413 体积过大
LCP 是否合理 Lighthouse 与 SDK 数值同页对比 iframe、影子根、元素已移除
资源耗时是否全 0 挑一条 CDN 资源看 responseStart Timing-Allow-Origin
软导航 PV 手动点路由后看是否产生新 pv 事件 只监听了首次 load
重复 flush 快速切换标签看上报条数是否翻倍 visibilitypagehide 未去重

小结

把上报做成"队列加空闲 flush 加离开兜底",用 sendBeacon 携带 JSON Blob,性能侧用 PerformanceObserverPerformanceNavigationTiming 对齐现代指标,并补上 CLSINP 的采集意识,错误侧区分资源与脚本并改用 composedPath,行为侧把 PV、软导航与可见停留时间说清楚,就是一个可演进的最小监控采集层。存储与查询、告警与大盘属于下一篇文章。

使用 Hooks 构建无障碍 React 组件

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

import { useReducedMotion } from "@reactuses/core";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

import { useReducedMotion } from "@reactuses/core";

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

import { usePreferredContrast } from "@reactuses/core";

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

import { usePreferredColorScheme } from "@reactuses/core";

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

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

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

import { useTextDirection } from "@reactuses/core";

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

把 JavaScript 原型讲透:从 `[[Prototype]]`、`prototype` 到 `constructor` 的完整心智模型

目录

  • 引言:为什么原型是前端工程师绕不过去的一课
  • 一、先建立统一认知:对象原型到底是什么
  • 二、prototype[[Prototype]] 不是一回事
  • 三、从 new 和内存视角理解实例、构造函数与原型
  • 四、函数原型上的高频知识点:共享属性与 constructor
  • 五、重写原型对象时,为什么最容易踩坑
  • 六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype
  • 实战建议
  • 总结:关键结论与团队落地建议

引言:为什么原型是前端工程师绕不过去的一课

很多团队在日常开发里已经很少手写“构造函数 + 原型”这套模式了,更多时候我们写的是 class、对象字面量、组合式函数,甚至直接用框架帮我们屏蔽底层细节。于是原型这件事,常常只在面试里出现,看起来像“八股”,但一旦线上排查问题,它又会突然变得非常真实:

  • 为什么两个实例的方法地址相同?
  • 为什么给对象赋值后没有覆盖到原型上的值?
  • 为什么重写 prototype 之后,constructor 看起来“不对了”?
  • 为什么控制台里 __proto__ 看起来什么都有,但代码里又不建议用它?
  • 为什么 class 最终仍然离不开原型链?

如果对这些问题没有统一心智模型,工程上就会出现两类常见问题:一类是“会用但讲不清”,另一类是“改得动但不敢改”。而原型真正的价值,不在于背定义,而在于帮助我们理解 JavaScript 的对象系统、继承机制、方法共享、内存结构,以及很多框架设计背后的语言基础。

这篇文章的目标很明确:不是把概念堆给你,而是把“对象、函数、构造函数、实例、原型、构造器”这几者之间的关系,一次性串起来。读完之后,你至少应该能建立起一个稳定的判断标准:什么应该挂在实例上,什么应该挂在原型上,什么时候可以重写原型,重写后又要补什么。


一、先建立统一认知:对象原型到底是什么

在 JavaScript 中,几乎每个对象都带着一个隐藏的内部链接,这个内部链接在规范里叫 [[Prototype]]。它会指向另一个对象,而这个“被指向的对象”,就是当前对象的原型对象。

你可以把它理解成:当前对象在找不到某个属性时,下一站该去哪里找。

1. 原型最核心的作用:兜底查找

当我们访问一个对象属性时,会触发内部的 [[Get]] 过程;当我们给对象设置属性时,会触发 [[Set]] 过程。

操作 触发时机 原型参与方式
[[Get]] 读取属性时 先查对象自身,找不到再沿原型向上查
[[Set]] 设置属性时 优先看当前对象及属性描述符,再决定是否在当前对象创建新属性

下面这个例子最能说明问题:

function A() {}
A.prototype.x = 10

const obj = new A()

console.log(obj.x) // 10,obj 自身没有 x,沿原型找到 A.prototype.x

obj.x = 20
console.log(obj.x) // 20,此时 obj 自身已经有了 x

这里发生了两件事:

  1. 第一次读 obj.x,对象自身没有,沿着原型找到 A.prototype.x
  2. 第二次写 obj.x = 20,是在实例自身新增了一个同名属性,而不是改掉原型上的 x

这也是很多人第一次理解“共享”和“遮蔽(shadowing)”的关键入口。

2. 对象字面量创建出来的对象,也有原型

很多人以为只有通过构造函数创建出来的对象才有原型,这其实不对。只要是普通对象,通常都有 [[Prototype]]

const obj = { name: 'XiaoWu' }
const foo = {}

console.log(obj.__proto__)
console.log(foo.__proto__)

图:隐式原型在浏览器与终端中的表现

控制台里你看到的结果,和真实运行时的内部结构并不完全等价。浏览器控制台为了方便调试,会把一些继承来的内容也展开给你看;Node 的输出则更接近“对象本身 + 原型关系”的表现。

从理解层面,可以先把它抽象成下面这样:

const obj = { name: 'XiaoWu', __proto__: {} }
const foo = { __proto__: {} }

当然,真正的 [[Prototype]] 不是你字面量里真的写出来的这个字段,而是引擎内部维护的链接关系。

3. __proto__[[Prototype]]Object.getPrototypeOf 到底什么关系?

这是高频混淆点,必须一次说清:

  • [[Prototype]]:规范层面的内部槽,真实存在,但你不能直接写代码访问这个名字
  • __proto__:历史遗留的访问器属性,调试方便,但不推荐作为正式代码依赖
  • Object.getPrototypeOf(obj):标准 API,推荐在正式代码里使用
const obj = { name: '小吴' }

console.log(Object.getPrototypeOf(obj))

调试场景里,obj.__proto__ 确实更顺手;工程代码里,优先使用 Object.getPrototypeOf(obj)。原因很简单:

  • 语义标准、跨环境更稳定
  • 可维护性更高
  • 降低“我在操作语言底层 hack 口子”的心智负担

顺手补一句:今天的引擎几乎都支持 __proto__,但“能用”不等于“应该作为主路径使用”。

本章小结

  • 每个对象的核心原型关系,体现在内部的 [[Prototype]]
  • 读取属性找不到时,会沿原型继续查找
  • 给实例赋值,不等于改原型;很多时候只是“在实例自身新增同名属性”
  • __proto__ 更适合调试,正式代码优先 Object.getPrototypeOf
  • 理解原型,本质是在理解 JavaScript 如何做“属性查找”和“能力复用”

二、prototype[[Prototype]] 不是一回事

聊原型最容易踩的第一个坑,就是把 prototype[[Prototype]] 混为一谈。它们名字很像,但角色完全不同。

1. prototype 是函数身上的属性,不是所有对象都有

先看例子:

function foo() {}

const obj = {}

console.log(foo.prototype) // 普通函数默认有 prototype
console.log(obj.prototype) // undefined,普通对象没有 prototype

这里有一个非常重要的判断标准:

  • prototype 是函数对象上的一个属性,主要给“作为构造函数使用”时服务
  • [[Prototype]] 是对象内部的原型链接,普通对象、函数对象都可能有

也就是说:

  • 函数是对象,所以函数也有 [[Prototype]]
  • 但普通对象不是函数,所以普通对象没有 prototype

2. 这两个概念各自负责什么?

可以直接用一句最工程化的话来理解:

  • prototype定义将来由这个构造函数创建出来的实例,应该共享什么
  • [[Prototype]]当前这个对象,实际沿哪条链路去查找属性

它们的职责并不重复:

  1. 归属不同
    prototype 属于函数;[[Prototype]] 属于对象

  2. 作用不同
    prototype 用来定义共享能力;[[Prototype]] 用来参与查找路径

  3. 时机不同
    prototype 通常在定义阶段配置;[[Prototype]] 通常在对象创建时被确定

3. 纠正一个特别容易出现的误区

很多人在刚学到这里时,会误以为:

“函数自己的隐式原型会指向它自己的显式原型”

这是错误的。

准确关系应该是:

  • foo.prototype:给将来 new foo() 出来的实例用
  • Object.getPrototypeOf(foo):函数对象 foo 自己的原型,通常是 Function.prototype

也就是说:

function foo() {}

console.log(Object.getPrototypeOf(foo) === Function.prototype) // true

而实例和构造函数之间的正确关系,是下一节的重点:

const f1 = new foo()
console.log(Object.getPrototypeOf(f1) === foo.prototype) // true

4. new 到底做了什么?

理解原型,绕不开 new。把它拆开看,会清晰很多。

new Foo() 大致会做下面几步:

  1. 创建一个全新的空对象
  2. 把这个对象的 [[Prototype]] 指向 Foo.prototype
  3. 用这个新对象作为 this 执行构造函数
  4. 如果构造函数没有显式返回对象,就返回这个新对象

所以,实例为什么能访问构造函数原型上的方法?答案就在第 2 步。

function Foo() {}

const f1 = new Foo()
const f2 = new Foo()

console.log(Object.getPrototypeOf(f1) === Foo.prototype) // true
console.log(Object.getPrototypeOf(f2) === Foo.prototype) // true

这就是为什么不同实例可以“共享一套方法定义”,却又拥有各自不同的数据。

本章小结

  • prototype[[Prototype]] 名字相似,但职责完全不同
  • 普通对象没有 prototype,函数通常有
  • 实例的 [[Prototype]] 会在 new 时指向构造函数的 prototype
  • 函数对象自己的原型通常是 Function.prototype,不是它自己的 prototype
  • 只要把“定义共享能力”和“参与属性查找”分开理解,很多混乱都会消失

三、从 new 和内存视角理解实例、构造函数与原型

如果只停留在语法层,原型会越学越抽象。真正把它看懂,最有效的方式是换成“引用关系”和“内存指向”的视角。

1. Person、实例对象和原型对象之间是什么关系?

先看一个最简单的例子:

function Person() {}

console.log(Person.prototype)

很多人看到这里会困惑:Person 是函数,Person.prototype 是对象,那实例和它们之间是怎么连起来的?

关键结论只有一个:

同一个构造函数创建出来的实例,默认会共享同一个原型对象。

这也是后面方法复用的基础。

图:从控制台结果理解构造函数与原型对象的关系

这张图适合帮助我们建立第一个直觉:构造函数不是孤立存在的,它天然带着一个 prototype 对象。

2. 为什么 p1p2 可以访问同一套原型内容?

function Person() {}

const p1 = new Person()
const p2 = new Person()

这里最值得记住的不是“创建了两个实例”,而是“这两个实例的原型指向同一个地方”。

console.log(Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2)) // true
console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:p1p2 实例对象共享同一个原型对象

这就解释了一个很重要的工程现象:

  • Person.prototype.xxx
  • 实际上影响的是所有还指向这个原型对象的实例
function Person() {}

const p1 = new Person()
const p2 = new Person()

console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:通过相等比较验证实例原型是否一致

3. 一个很适合面试和排错的思考题:p1.name 到底能从哪里拿到?

假设 p1 自身没有 name,那 p1.name 还能不能拿到值?

答案是能,而且方式不止一种。本质上,这些方式最终都在改同一个共享原型对象。

function Person() {}

const p1 = new Person()
const p2 = new Person()

Object.getPrototypeOf(p1).name = '小吴'
console.log(p1.name) // 小吴

Person.prototype.name = 'XiaoWu'
console.log(p1.name) // XiaoWu

Object.getPrototypeOf(p2).name = 'why'
console.log(p1.name) // why

为什么第三种改 p2 的原型,也会影响 p1

因为:

Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) === Person.prototype

它们最终都指向同一个共享对象。

把这个关系进一步抽象成“内存地址”,就更容易理解了。你可以把上面的变化想成:

// 假设共享原型对象就像一个地址 0x100
0x100.name = '小吴'
console.log(0x100.name) // 小吴

0x100.name = 'XiaoWu'
console.log(0x100.name) // XiaoWu

0x100.name = 'why'
console.log(0x100.name) // why

图:从“内存指向”视角理解实例、构造函数与原型的关系

这个视角非常关键,因为后面理解“共享方法”“重写原型”“原型链继承”时,本质都是在理解引用关系,而不是背结论。

本章小结

  • 同一个构造函数创建的实例,默认共享同一个原型对象
  • Object.getPrototypeOf(p1) === Person.prototype 是原型学习中的第一条黄金验证公式
  • 改共享原型,相当于影响所有还连接到它的实例
  • 原型问题一旦抽象成“引用地址”,很多现象都会变得很好解释
  • 面试里问“为什么改 p2 的原型会影响 p1”,本质在考你是否理解“共享引用”

四、函数原型上的高频知识点:共享属性与 constructor

前面讲的是“为什么原型存在”,这一节讲“原型上通常放什么”。

1. 原型上放的是“共享能力”

在 JavaScript 中,函数的 prototype 对象,本质上就是给实例共享用的。

function Person() {}

Person.prototype.name = 'why'
Person.prototype.age = 18

const p1 = new Person()
const p2 = new Person()

console.log(p1.name, p2.age) // why 18

这意味着:

  • nameage 不在 p1p2 自身上
  • 它们来自共享原型
  • 所有实例都能访问,但并不各自拷贝一份

图:往原型上添加共享属性后的结构示意

这里顺便给一个工程建议:
如果一个值会因实例不同而不同,就不要放原型上;如果一段行为对所有实例都一致,就优先考虑放原型上。

2. constructor 是什么?为什么平时看不见?

默认情况下,函数的原型对象上会有一个 constructor 属性,它指回构造函数本身。

function Foo() {}

console.log(Foo.prototype.constructor === Foo) // true

但很多同学在控制台直接打印 Foo.prototype 时,看见的是个空对象,于是误以为它什么都没有。其实不是没有,而是:

constructor 默认是不可枚举的。

所以直接打印、遍历时看不明显,但你可以通过属性描述符把它“看见”。

function Foo() {}

console.log(Foo.prototype) // 看起来像 {}
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

图:在 Node 中查看 constructor 的真实属性描述符

3. constructor 存在的意义是什么?

constructor 的工程意义,不是“让你炫技”,而是帮我们保留一条从原型对象追溯回构造函数的路径。

function Foo() {}

console.log(Foo.prototype.constructor.name) // Foo

这相当于让原型系统形成了一个闭环:

  • 实例通过 [[Prototype]] 指向原型对象
  • 原型对象通过 constructor 指回构造函数

这条关系能帮助我们做理解、调试和某些类型判断。但也要注意一点:

constructor 可以被改写,所以它不是绝对可靠的类型判断依据。

在工程里,如果你想做类型判断:

  • 优先考虑 instanceof
  • 或者基于更稳定的品牌判断方式
  • 不要把 constructor 当成唯一真理

4. 一个有意思但不建议滥用的闭环验证

function Foo() {}

console.log(
  Foo.prototype.constructor.prototype.constructor.prototype.constructor.name
) // Foo

这段代码能跑通,不是因为 JavaScript 神秘,而是因为这条引用关系本来就存在。
不过知道就好,别把它写进业务代码里。

本章小结

  • 原型对象最适合承载共享属性和共享方法
  • constructor 默认存在于函数原型对象上,只是不可枚举
  • Foo.prototype.constructor === Foo 是默认成立的
  • constructor 适合理解原型结构,但不适合作为唯一类型判断依据
  • 共享逻辑放原型,是 JavaScript 节省内存、复用能力的关键设计

五、重写原型对象时,为什么最容易踩坑

前面讲的是“给现有原型追加内容”,这一节讲的是另一种更激进的操作:直接重写整个原型对象。

1. 什么叫“重写原型对象”?

不是这样:

Person.prototype.name = '小吴'
Person.prototype.age = 20

而是这样:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 20,
  learn() {
    console.log(this.name + '在学习')
  }
}

这种写法在属性比较多时很常见,结构也更集中。

先看原始的“构造函数与原型相互关联”视角:

图:默认原型对象与构造函数之间的关联

当你执行 Person.prototype = { ... } 时,本质上是让 Person.prototype 指向了一个全新的对象

图:重写原型后,构造函数指向了新的原型对象

继续把内容填进去之后,新的结构才完整:

图:新的原型对象被填充内容后的状态

2. 这里最容易掉的坑:constructor 丢了

看下面的代码:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

const f1 = new Person()
console.log(f1.name + '今年' + f1.age) // 小吴今年18

功能看起来没问题,但有一个隐藏变化:

console.log(Person.prototype.constructor === Person) // false
console.log(Person.prototype.constructor === Object) // true

原因并不复杂:

  • 默认创建函数时,引擎会为它生成一个带 constructor 的原型对象
  • 但你手动赋值的新对象只是一个普通对象字面量
  • 它自己的 constructor 并不是 Person
  • 查找时会沿着这个新对象的原型往上找到 Object.prototype.constructor

图:重写原型后,实例仍能访问属性,但 constructor 关系已发生变化

3. 正确做法:手动把 constructor 补回去

最常见的补法如下:

function Foo() {}

Foo.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

Object.defineProperty(Foo.prototype, 'constructor', {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Foo
})

const f1 = new Foo()
console.log(f1.name + '今年' + f1.age)

为什么不用下面这种简单写法?

Foo.prototype = {
  constructor: Foo,
  name: '小吴'
}

因为这样写出来的 constructor 默认是可枚举的,而原生默认行为里,这个属性应该是不可枚举的。
如果你想尽量保持和原生行为一致,Object.defineProperty 更合适。

图:补回 constructor 后,构造函数与新原型对象重新闭合

4. 再补一个容易忽略的边界条件

很多人以为“重写原型后,旧原型会立即消失”,这其实不严谨。

更准确的说法是:

  • 如果旧原型对象已经没有任何可达引用,后续才可能被垃圾回收
  • 如果已有实例还指向旧原型,那旧原型仍然活着

例如:

function Person() {}

const oldP = new Person()

Person.prototype = {
  sayHello() {
    console.log('hello')
  }
}

const newP = new Person()

console.log(Object.getPrototypeOf(oldP) === Object.getPrototypeOf(newP)) // false

这在排查“为什么新老实例行为不一致”时非常关键。

本章小结

  • prototype 追加属性,和直接重写整个 prototype,是两种不同操作
  • 重写原型后,默认的 constructor 关联会丢失
  • 推荐用 Object.definePropertyconstructor 补回去
  • 重写原型不会自动“更新”旧实例的原型指向
  • 原型对象是否回收,取决于是否还有引用,而不是“看起来不用了”

六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype

这是原型章节里最重要的工程落点。

1. 一个典型错误:把实例数据塞进共享原型

下面这段代码看似“想省事”,实则会制造共享数据污染:

function Person(name, age, sex, address) {
  Person.prototype.name = name
  Person.prototype.age = age
  Person.prototype.sex = sex
  Person.prototype.address = address
}

const p1 = new Person('小吴', 18, '男', '福建')
console.log(p1.name) // 小吴

const p2 = new Person('why', 35, '男', '广州')
console.log(p1.name) // why

为什么 p1.name 最后变成了 why

因为你不是把数据放进 p1p2 自身,而是放进了它们共享的 Person.prototype
这等于让所有实例共用一份可变数据,自然后创建的实例会覆盖前一个实例的结果。

这类问题在工程里很致命,因为它会造成一种非常糟糕的现象:对象看起来是独立的,实际状态却是串联的。

2. 正确做法:实例数据归实例,共享方法归原型

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

Person.prototype.eating = function () {
  console.log(this.name + '今天吃烤地瓜了')
}

Person.prototype.running = function () {
  console.log(this.name + '今天跑了五公里')
}

const p1 = new Person('小吴', 18, '男', '福建')
const p2 = new Person('why', 35, '男', '广州')

console.log(p1.name) // 小吴
console.log(p2.name) // why
console.log(p1.eating === p2.eating) // true

这套写法有三个直接收益:

  1. 实例数据隔离
    每个对象维护自己的状态,不会相互覆盖

  2. 方法共享
    所有实例共用同一个方法引用,减少重复创建

  3. 结构清晰
    一眼能分清“对象自己的数据”和“对象共享的行为”

3. 为什么不要把原型方法写进构造函数内部?

有些代码会这么写:

function Person(name) {
  this.name = name
  this.eating = function () {
    console.log(this.name + '在吃东西')
  }
}

它不是不能运行,而是有明显代价:每次 new Person() 都会重新创建一个新的函数对象。

如果实例特别多,这就是实打实的重复内存占用和不必要的函数分配。

更合理的方式还是:

function Person(name) {
  this.name = name
}

Person.prototype.eating = function () {
  console.log(this.name + '在吃东西')
}

4. 这套模式和 class 有什么关系?

如果你已经在写 class,那更应该理解这部分。因为:

class Person {
  constructor(name) {
    this.name = name
  }

  eating() {
    console.log(this.name + '在吃东西')
  }
}

本质上仍然是:

  • constructor 里放实例数据
  • 方法定义在原型上

class 改变的是写法,不是底层原理。

本章小结

  • 实例间不同的数据,放 this
  • 所有实例共享的行为,放 prototype
  • 不要把可变实例数据放到共享原型上
  • 不要在构造函数里重复创建所有实例都相同的方法
  • 理解这条原则后,再看 class 会非常顺手

实战建议

1. 代码评审时重点看这几件事

  • 是否把实例级数据错误地挂到了原型上
  • 是否把共享方法错误地定义在构造函数内部
  • 是否在重写 prototype 后忘了补 constructor
  • 是否在正式代码里依赖 __proto__ 而不是标准 API
  • 是否出现“旧实例”和“新实例”指向不同原型的潜在风险

2. 调试原型问题时,建议这样验证

console.log(Object.getPrototypeOf(obj))
console.log(Object.getPrototypeOf(obj) === Foo.prototype)
console.log(obj.hasOwnProperty('xxx'))
console.log('xxx' in obj)
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

这一组排查动作,足够覆盖大多数原型相关问题:

  • 属性是自己的,还是继承来的
  • 当前实例到底连到哪个原型对象
  • 原型对象上的属性描述符是否符合预期
  • constructor 是否被改坏了

3. 团队内可以落地的约束

  • 约定:实例状态一律放 this / 类字段
  • 约定:共享方法统一放原型 / 类方法
  • 约定:禁止在业务代码里直接依赖 __proto__
  • 约定:重写 prototype 必须同步恢复 constructor
  • 约定:在 Code Review Checklist 中加入“原型污染”和“共享引用”检查项

4. 性能与可维护性的权衡

  • 小量对象场景下,差异可能不明显
  • 大量实例场景下,方法是否共享会带来真实内存差异
  • 动态改原型虽然灵活,但会明显增加维护成本
  • 原型越“魔法化”,后续新人接手成本越高

总结:关键结论与团队落地建议

JavaScript 的原型并不神秘,它本质上解决的是两个问题:

  1. 对象找不到属性时,去哪里继续找
  2. 多个实例如何共享同一套行为定义

把这两件事想清楚,原型就不再是零散知识点,而是一套完整的对象模型。

最后用几条结论收尾:

  • [[Prototype]] 是对象的查找链路,prototype 是构造函数为实例准备的共享模板
  • new 的关键一步,是把实例的 [[Prototype]] 指向构造函数的 prototype
  • constructor 默认存在于原型对象上,只是不可枚举
  • 重写 prototype 会改变后续实例的继承来源,同时可能破坏 constructor
  • 最稳妥的工程实践是:实例数据放 this,共享方法放 prototype

如果要在团队内部继续往下沉淀,建议下一步把下面几个主题串起来学习:

  • 原型链完整查找过程
  • instanceof 的底层判断逻辑
  • Object.create 与显式指定原型
  • 组合继承、寄生组合继承
  • class extends 背后的原型链本质

当你把这些知识连起来之后,JavaScript 的对象系统就不再是“记忆题”,而会变成你分析框架、阅读源码、设计抽象时的一套底层能力。

React 拖拽:无需第三方库的完整方案

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 React 教程一上来就引入像 react-dnddnd-kit 这样的重量级库——它们功能强大,但对许多常见场景来说增加了过多的包体积和概念负担。

如果只需一次 Hook 调用就能获得流畅、可用于生产的拖拽行为呢?本文将从原生浏览器 API 出发,分析它们为何难用,然后用 ReactUse 中的两个轻量 Hook:useDraggableuseDropZone 来解决同样的问题。

手动实现:自行处理指针事件

让元素可拖拽的最基本方式是手动监听 pointerdownpointermovepointerup 事件。通常的写法如下:

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

function ManualDraggable() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const delta = useRef({ x: 0, y: 0 });

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

    const onPointerDown = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      setIsDragging(true);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!isDragging) return;
      setPosition({
        x: e.clientX - delta.current.x,
        y: e.clientY - delta.current.y,
      });
    };

    const onPointerUp = () => setIsDragging(false);

    el.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);

    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
    };
  }, [isDragging]);

  return (
    <div
      ref={ref}
      style={{
        position: "fixed",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
      }}
    >
      拖动我
    </div>
  );
}

能跑起来——但看看你需要管理多少状态。而这还只是最简单的版本。实际需求会迅速叠加更多复杂性。

为什么手动实现拖拽很难

上面的代码片段有几个不足之处,一旦超出 Demo 级别就会立刻暴露出来:

  1. 容器边界。 如果你想让元素保持在父容器内部,就需要在每次移动时读取容器尺寸并限制位置。这意味着每帧都要在两个元素上调用 getBoundingClientRect

  2. 指针类型。 上面的代码处理了鼠标事件,但触控和手写笔呢?PointerEvent API 统一了它们,但按指针类型过滤(例如禁止手写笔拖动)需要额外的条件判断。

  3. 拖拽手柄。 有时可拖拽的触发区域只是卡片内部的一个标题栏。你需要将"触发"元素和"移动"元素分离,并相应地连接事件。

  4. 事件清理。 忘记移除监听器——或者在 useEffect 中使用了错误的依赖——会导致诸如松开鼠标后元素仍在移动之类的隐蔽 Bug。

  5. 放置区域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。协调这些事件——尤其是子元素上臭名昭著的 dragenter/dragleave 闪烁问题——非常容易出错。

这些正是 useDraggableuseDropZone 开箱即用要解决的问题。

useDraggable:一个 Hook,完全掌控

useDraggable 接受一个目标元素的 ref 和一个可选的配置对象。它返回当前的 xy 位置、一个表示元素是否正在被拖拽的布尔值,以及一个 setter(用于程序化地移动元素)。

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

function DraggableCard() {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    initialValue: { x: 100, y: 100 },
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: isDragging ? "#4338ca" : "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        transition: isDragging ? "none" : "box-shadow 0.2s",
        boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
        userSelect: "none",
        touchAction: "none",
      }}
    >
      随意拖动我
    </div>
  );
}

这就是整个组件。无需手动事件监听器。无需清理逻辑。触控、鼠标和手写笔默认都能工作。

限制在容器内

传入一个 containerElement ref,Hook 会自动夹紧位置,使元素不会离开容器:

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

function BoundedDrag() {
  const container = useRef<HTMLDivElement>(null);
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    containerElement: container,
    initialValue: { x: 0, y: 0 },
  });

  return (
    <div
      ref={container}
      style={{
        position: "relative",
        width: 400,
        height: 300,
        border: "2px dashed #cbd5e1",
        borderRadius: 8,
      }}
    >
      <div
        ref={el}
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: 80,
          height: 80,
          background: "#4f46e5",
          borderRadius: 8,
          cursor: isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
      />
    </div>
  );
}

无需手动的夹紧计算。Hook 会读取容器的滚动和客户端尺寸,自动限制元素位置。

使用拖拽手柄

通常你只想让元素的特定部分——比如一个标题栏——触发拖拽。传入 handle ref 即可:

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

function DraggablePanel() {
  const panel = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(panel, {
    handle,
    initialValue: { x: 200, y: 150 },
  });

  return (
    <div
      ref={panel}
      style={{
        position: "fixed",
        left: x,
        top: y,
        width: 280,
        background: "#fff",
        borderRadius: 8,
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        overflow: "hidden",
        touchAction: "none",
      }}
    >
      <div
        ref={handle}
        style={{
          padding: "8px 12px",
          background: "#4f46e5",
          color: "#fff",
          cursor: isDragging ? "grabbing" : "grab",
          userSelect: "none",
        }}
      >
        从这里拖动
      </div>
      <div style={{ padding: 12 }}>
        <p>此内容区域不会触发拖拽。</p>
      </div>
    </div>
  );
}

面板的主体仍然是可交互的——你可以选择文本、点击按钮或滚动——而只有标题栏是拖拽触发器。

useDropZone:轻松实现文件拖放

useDropZone 解决拖放的另一半:接收放置。它处理全部四个拖拽事件(dragenterdragoverdragleavedrop),阻止浏览器默认打开文件的行为,并通过内部计数器解决了 dragleave 闪烁问题。

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

function FileUploader() {
  const dropRef = useRef<HTMLDivElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const isOver = useDropZone(dropRef, (droppedFiles) => {
    if (droppedFiles) {
      setFiles((prev) => [...prev, ...droppedFiles]);
    }
  });

  return (
    <div
      ref={dropRef}
      style={{
        padding: 40,
        border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
        borderRadius: 8,
        background: isOver ? "#eef2ff" : "#f8fafc",
        textAlign: "center",
        transition: "all 0.15s",
      }}
    >
      {isOver ? (
        <p>松开以上传</p>
      ) : (
        <p>将文件拖到这里上传</p>
      )}
      {files.length > 0 && (
        <ul style={{ textAlign: "left", marginTop: 16 }}>
          {files.map((f, i) => (
            <li key={i}>
              {f.name} ({(f.size / 1024).toFixed(1)} KB)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

isOver 布尔值让你在文件进入时立即重新设置区域样式,给用户清晰的视觉反馈。无需 e.preventDefault() 样板代码,不用和闪烁的 dragleave 事件斗争。

构建看板风格的卡片拖动

让我们在一个更贴近实际的例子中结合两个 Hook——一个可拖拽的卡片,松开时弹回原位,以及一个接受它的放置区域。我们还将使用 useElementBounding 来读取区域位置以做视觉反馈。

import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";

interface Task {
  id: string;
  title: string;
}

function KanbanBoard() {
  const [todo, setTodo] = useState<Task[]>([
    { id: "1", title: "设计原型" },
    { id: "2", title: "编写 API 规范" },
  ]);
  const [done, setDone] = useState<Task[]>([
    { id: "3", title: "搭建 CI 流水线" },
  ]);

  const doneZoneRef = useRef<HTMLDivElement>(null);
  const todoZoneRef = useRef<HTMLDivElement>(null);

  const isOverDone = useDropZone(doneZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const isOverTodo = useDropZone(todoZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const doneBounds = useElementBounding(doneZoneRef);

  return (
    <div style={{ display: "flex", gap: 24, padding: 24 }}>
      <div>
        <h3>待办</h3>
        <div
          ref={todoZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverTodo ? "#fef3c7" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {todo.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onDrop={() => {
                setTodo((prev) => prev.filter((t) => t.id !== task.id));
                setDone((prev) => [...prev, task]);
              }}
              targetBounds={doneBounds}
            />
          ))}
        </div>
      </div>
      <div>
        <h3>完成</h3>
        <div
          ref={doneZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverDone ? "#d1fae5" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {done.map((task) => (
            <div
              key={task.id}
              style={{
                padding: 12,
                marginBottom: 8,
                background: "#fff",
                borderRadius: 6,
                boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
              }}
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function TaskCard({
  task,
  onDrop,
  targetBounds,
}: {
  task: Task;
  onDrop: () => void;
  targetBounds: ReturnType<typeof useElementBounding>;
}) {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging, setPosition] = useDraggable(el, {
    initialValue: { x: 0, y: 0 },
    onEnd: (pos) => {
      // 检查卡片是否在"完成"列上方释放
      if (
        targetBounds &&
        pos.x >= targetBounds.left &&
        pos.x <= targetBounds.right &&
        pos.y >= targetBounds.top &&
        pos.y <= targetBounds.bottom
      ) {
        onDrop();
      }
      // 弹回原始位置
      setPosition({ x: 0, y: 0 });
    },
  });

  return (
    <div
      ref={el}
      style={{
        position: "relative",
        left: x,
        top: y,
        padding: 12,
        marginBottom: 8,
        background: isDragging ? "#e0e7ff" : "#fff",
        borderRadius: 6,
        boxShadow: isDragging
          ? "0 8px 24px rgba(0,0,0,0.15)"
          : "0 1px 3px rgba(0,0,0,0.1)",
        cursor: isDragging ? "grabbing" : "grab",
        zIndex: isDragging ? 50 : 1,
        touchAction: "none",
        userSelect: "none",
        transition: isDragging ? "none" : "all 0.2s ease",
      }}
    >
      {task.title}
    </div>
  );
}

几个值得注意的关键点:

  • useElementBounding 为我们提供了"完成"列的实时 leftrighttopbottom 值,以便在拖拽结束时进行碰撞检测。
  • onEnd 回调在未落在目标上时将卡片弹回 { x: 0, y: 0 }。配合 CSS transition 产生令人满意的橡皮筋效果。
  • 无需外部状态库。React 的 useState 对于这个复杂度完全够用。

配合其他 Hook 增强体验

ReactUse 的 Hook 天然可组合。以下是扩展上述示例的几种方式:

  • useMouse ——全局追踪光标位置,在拖拽过程中显示自定义拖拽光标或跟随指针的浮动提示。
  • useEventListener ——附加一个 keydown 监听器,在用户按下 Escape 时取消拖拽。
  • useElementSize ——动态读取容器的宽高以计算网格对齐位置(例如将 x 舍入到单元格宽度的最近倍数)。

例如,使用 useEventListener 添加 Escape 取消只需几行代码:

import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";

function CancelableDrag() {
  const el = useRef<HTMLDivElement>(null);
  const [x, y, isDragging, setPosition] = useDraggable(el);

  useEventListener("keydown", (e: KeyboardEvent) => {
    if (e.key === "Escape" && isDragging) {
      setPosition({ x: 0, y: 0 });
    }
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
    >
      拖动我(按 Esc 重置)
    </div>
  );
}

什么时候仍然需要完整的库

useDraggableuseDropZone 用最少的代码覆盖了绝大多数拖放场景。然而,如果你的需求包含复杂的可排序列表(带动画过渡)、具有键盘无障碍访问的多容器排序,或包含上千项的虚拟化列表,像 dnd-kit 这样的专用库仍然是更好的选择。关键在于,你并不需要在每种情况下都引入一个——对许多项目来说,一对 Hook 就足够了。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100+ 个 React Hook。探索所有 Hook →


本文最初发布于 ReactUse 博客

深入浅出 AST:解密 Vite、Babel编译的底层“黑盒”

前言

在前端开发中,我们每天都在写 JSX、TypeScript、Vue SFC,但浏览器其实根本看不懂这些。是谁把这些高级语法翻译成了浏览器能执行的 JS?答案就是 AST(Abstract Syntax Tree,抽象语法树) 。它是所有前端构建工具(Vite、Webpack、ESBuild、Babel)的灵魂。

一、 核心概念:什么是 AST?

AST(Abstract Syntax Tree,抽象语法树) ,是代码的结构化数据表示。简单来说,就是把原本一行行纯文本形式的代码,剥离无关的格式、空格、注释等冗余信息,转换成一棵有层级、有嵌套、有明确语法逻辑的树状对象。

  • 转换的核心意义:让计算机能够真正读懂代码的含义,而不是把代码当成普通字符串处理。有了AST,机器才能精准分析代码结构、修改代码逻辑、实现各类编译构建功能。

  • 例子const a = 1 在 AST 中会被拆解为:一个变量声明节点、一个标识符 a 和一个数字字面量 1


二、 AST的编译与生成流程

代码转换通常经历以下四个标准阶段:

  1. 词法分析 (Tokenization) :将长字符串拆解为最小语法单元(Tokens)。例如把 const a = 1 拆成 consta=1

  2. 语法分析 (Parsing) :在通过词法分析得到零散的Tokens后,语法分析会根据对应的语言规范(JS规范、Vue模板规范等),将这些无序的Tokens按照语法规则,组装成具有嵌套依赖关系的节点树,也就是最终的AST。这一步会确立代码的语法结构,比如声明语句、赋值语句、函数定义等节点的层级关系。

  3. 转换 (Transformation) :这是各类编译工具的核心工作区,比如Babel、ESBuild、Rollup的关键逻辑都在这一步。工具会深度遍历AST上的每一个节点,根据需求对节点进行修改、新增、删除操作,比如语法降级、代码替换、依赖处理等,改造出符合目标要求的新AST。

  4. 代码生成 (Code Generation) :完成AST的修改后,最后一步就是逆向操作:把改造后的树状AST,重新转换回纯文本形式的可执行代码,完成整个编译构建流程。


三、AST的核心应用场景

AST是前端工程化的底层基石,几乎所有主流的构建、转译、优化工具,都是基于AST实现的,核心应用场景包括:

  • 代码转译(ES6+转ES5、TS转JS、Vue/React编译)
  • 依赖预构建与依赖分析
  • Tree Shaking(无用代码剔除)
  • 代码压缩、混淆、格式化
  • 静态代码检查(ESLint)
  • 框架单文件组件编译(Vue SFC、React JSX)

四、 AST 在 Vite 中的降维打击

Vite作为新一代前端构建工具,凭借超快的启动速度和构建效率出圈,而这一切高效能力的底层,都离不开AST的支撑。下面详解AST在Vite四大核心场景中的具体作用。

1. 依赖预构建 (Pre-bundling)

依赖预构建是Vite启动速度远超Webpack的核心秘诀,而AST则是依赖预构建的核心底层支撑,具体执行流程:

  1. Vite会深度解析第三方依赖包代码(比如lodash-es、axios等),先将代码文本转换为AST;
  2. 遍历AST节点,精准识别出所有 import/export 语句(或CommonJS的 require 语句),梳理清楚第三方包的内部依赖关系;
  3. 修改AST节点:将不兼容浏览器的CommonJS语法,转换成浏览器原生支持的ESM模块化语法;
  4. 继续优化AST,把零散的多个依赖文件,合并成少数几个文件,减少网络请求;
  5. 将修改后的AST重新生成代码文本,缓存到 node_modules/.vite 目录下,供浏览器直接加载。

2. ESBuild 转译

Vite在开发阶段选用Go 编写的 ESBuild 进行快如闪电的转译,实现TS转JS、ES6+语法降级等能力,而ESBuild的核心工作原理就是基于AST处理

  1. ESBuild读取TS/TSX源码,将其解析生成标准AST;
  2. 遍历AST节点,剔除TS特有的语法节点(比如类型注解const a: number = 1),保留纯JS逻辑;
  3. 对ES6+高阶语法节点(箭头函数、解构赋值、可选链等)进行转换,替换为ES5兼容的AST节点;
  4. 将转换后的AST生成纯JS代码文本,返回给浏览器加载执行。

3. 按需导入与 Tree Shaking

Vite生产环境打包底层基于Rollup,而Tree Shaking(剔除无用代码、实现按需引入)完全依赖AST实现:

  1. Rollup解析项目源码,生成完整的AST;
  2. 深度遍历AST,跟踪代码的引用关系,精准识别出未被调用、未被引用的无用代码节点(比如未使用的函数、变量、模块);
  3. 从AST中直接删除这些无用节点,精简AST结构;
  4. 将精简后的AST重新生成代码文本,大幅减少打包体积,实现代码瘦身。

4. Vue SFC 单文件组件编译

在Vite+Vue项目中,@vitejs/plugin-vue 插件负责解析.vue单文件组件,AST是整个编译流程的核心:

  1. 插件先将.vue文件拆分为 <template><script><style> 三大核心模块;
  2. 针对 <template> 模板:生成专属的Vue模板AST(结构类似JS AST,针对模板语法优化),再将模板AST进一步转换成渲染函数(render函数)对应的JS AST;
  3. 针对 <script setup> 脚本:解析JS AST,处理 definePropsdefineEmitsdefineExpose 等Vue语法糖,将其转换为浏览器可识别的普通JS代码;
  4. 最后合并所有模块的AST,生成浏览器可直接运行的完整JS代码,完成Vue组件编译。

📝 总结与启发

AST 是前端工程化的“上帝视角”。掌握了它,你就掌握了编写 Lint 工具、代码加密、自动重构脚本 以及 自定义 Babel/Vite 插件 的能力。

前端模块化:CommonJS、AMD、ES Module三大规范全解析

前言

在前端工程化日益庞大的今天,模块化已成为基石。从最初的“全局变量污染”到如今的“万物皆可模块”,前端社区经历了漫长的探索。本文将深度解析业界主流的三大模块规范:CommonJSAMDES Module

一、 CommonJS:服务端的先行者

CommonJS 是最早正式提出的 JavaScript 模块规范,伴随着 Node.js 的诞生而风靡。

1. 核心语法

  • 导出:使用 module.exportsexports
  • 导入:使用 require
// a.js
const add = (a, b) => a + b;
module.exports = { add };

// main.js
const { add } = require('./a.js');
console.log(add(1, 2));

2. 局限性与挑战

  • 环境依赖:模块加载器由 Node.js 提供,高度依赖运行时环境。
  • 同步阻塞:CommonJS 规定模块加载是同步的。在服务端(磁盘读取)这没问题,但在浏览器端(网络请求),同步加载会导致 JS 解析阻塞,造成页面假死。

二、 AMD:浏览器的异步解法

为了解决 CommonJS 在浏览器端的同步阻塞问题,AMD (Asynchronous Module Definition) 应运而生。

1. 核心语法

AMD规范依赖第三方库(如RequireJS)实现,通过 define() 函数定义模块:第一个参数声明依赖模块数组,第二个参数为回调函数,依赖加载完成后执行;模块导出通过return实现。

// print.js 定义无依赖的模块
define(function () {
  // 模块内部逻辑
  function print(msg) {
    console.log("print " + msg);
  }
  // return 导出模块成员
  return {
    print
  };
});

// main.js 定义有依赖的模块
// 第一个参数:依赖模块列表;第二个参数:依赖加载完成后的回调
define(["./print"], function (printModule) {
  // 使用依赖模块的方法
  printModule.print("main");
});

2. 存在的不足

  • 非原生支持:需要引入第三方的 loader(如著名的 RequireJS)。
  • 开发成本:书写格式相对复杂,代码逻辑被包裹在回调函数中,阅读和维护成本较高。

三、 ES Module (ESM):终极统一方案

ES Module(ESM) 是ECMAScript官方推出的模块化标准,也是目前现代前端工程化的唯一标准,浏览器和Node.js均已原生支持,完美解决了前两种规范的缺陷。

1. 核心语法

  • 导出exportexport default
  • 导入import
// lib.js
export const version = '1.0.0';
export default function MyFunc() {}

// main.js
import MyFunc, { version } from './lib.js';

2. 为什么它是最优解?

  • 编译时加载(静态分析) :ESM 在代码执行前就能确定模块依赖关系,这使得 Tree-shaking(摇树优化) 成为可能。
  • 原生支持:现代浏览器通过 <script type="module"> 即可直接运行,无需转换。
  • 异步加载:天然支持异步,不会阻塞页面渲染。

四、 核心对比:CommonJS vs AMD vs ESM

维度 CommonJS AMD ES Module
加载方式 同步加载 异步加载 静态编译/异步加载
运行环境 主要用于服务端 (Node.js) 浏览器端 (需 Loader) 浏览器/服务端通用
典型代表 Node.js RequireJS Vite, Webpack, 现代浏览器

五、 总结与趋势

  1. CommonJS 依然是 Node.js 生态的基石,但在向 ESM 过渡。
  2. AMD 已逐渐退出历史舞台,基本被打包工具(如 Webpack)内部处理。
  3. ESM 是未来,无论是前端框架(Vue3/React)还是构建工具(Vite),都在全面拥抱 ESM。

异步编程:从“回调地狱”到“async/await”的救赎之路

JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场“程序员不熬夜”的运动。

前言

你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProductInfo(details.productId, function(product) {
        console.log(product);
      });
    });
  });
});

这就是传说中的回调地狱——代码像楼梯一样往右歪,看得人头晕眼花。

今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。

一、为什么需要异步?

JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。

异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去“慢慢做”,JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:“嘿,我完事了,你处理一下结果吧。”

这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种“不干等”的机制。

二、回调函数:异步的原始形态

回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。

function fetchData(callback) {
  setTimeout(() => {
    callback('数据来了');
  }, 1000);
}

fetchData(function(data) {
  console.log(data); // 一秒后输出:数据来了
});

看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。

回调地狱长什么样?

// 先获取用户
getUser(function(user) {
  // 再根据用户ID获取订单
  getOrders(user.id, function(orders) {
    // 再获取第一个订单的详情
    getOrderDetails(orders[0].id, function(details) {
      // 再根据商品ID获取商品信息
      getProductInfo(details.productId, function(product) {
        // 终于拿到了
        console.log(product);
      });
    });
  });
});

代码往右飞,一眼看不到头。这还没算错误处理——每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。

回调的痛点

  • 嵌套太深,代码可读性差
  • 错误处理困难,每个回调都要try-catch
  • 难以并行执行多个异步操作

三、Promise:打破地狱的“链式反应”

ES6引入了Promise,它像是一个“承诺”:现在还没有结果,但将来一定会有(要么成功,要么失败)。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('数据来了');
    // 如果出错:reject('错误信息')
  }, 1000);
});

promise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProductInfo(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error(error));

看,从“右飞”变成了“下飞”,代码清晰多了。

Promise的几个关键点

  1. 状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。

  2. 链式传递then返回的是一个新的Promise,所以可以一直链下去。

  3. 错误冒泡:只要链尾有一个catch,前面任何一个环节出错都会落进来。

  4. 并行操作Promise.all等待所有完成,Promise.race等待最快的一个。

// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
  .then(([user, orders, product]) => {
    console.log('全部完成', user, orders, product);
  });

Promise解决了回调地狱的问题,但还是有些繁琐——你需要写很多.then.catch,而且处理复杂的逻辑时,还是有点绕。

四、async/await:异步代码同步写

ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。

async function getProductInfo() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const product = await getProductInfo(details.productId);
    console.log(product);
  } catch (error) {
    console.error(error);
  }
}

关键点

  • async标记的函数返回一个Promise
  • await后面跟一个Promise,它会“暂停”函数执行,直到Promise出结果
  • 错误处理直接用try/catch,和同步代码一模一样

这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。

但注意:await会阻塞函数内部,但不阻塞外部

async function test() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2

await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。

五、事件循环:异步背后的幕后黑手

说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。

JS的执行机制大概是这样的:

  1. 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
  2. 异步任务完成后,回调函数被放入任务队列
  3. 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
  4. 这个过程不断重复,就是事件循环。

任务队列还分宏任务微任务

  • 宏任务:setTimeout、setInterval、I/O操作、UI渲染
  • 微任务:Promise.then、MutationObserver、queueMicrotask

执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1,4,3,2

为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。

六、实战:封装一个带超时的fetch

我们来用async/await封装一个实用的网络请求函数:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

// 使用
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。

七、异步编程的最佳实践

  1. 能用async/await就用:比原生Promise更易读,错误处理也更自然。

  2. 避免“忘掉await”:忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。

  3. 并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。

// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();

// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
  1. 错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。

  2. 避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。

// 这样会串行执行,很慢
for (const id of ids) {
  const item = await fetchItem(id);
  items.push(item);
}

// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));

八、总结:从地狱到天堂

JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:

  • 回调函数:原始但容易陷入地狱
  • Promise:链式调用打破嵌套
  • async/await:让异步代码回归同步的直觉

现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。

明天我们将深入JS的另一座大山——事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。

如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!

# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(二)

一个完整的类 ChatGPT 对话系统,支持流式输出、打断,会话历史,前后端分离架构,非常适合拿来练手熟悉技术实现或者面试使用,接上一篇前端

基于 Spring Boot 2.7.18 + MyBatis-Plus + JWT 的 AI 对话系统后端服务。

技术栈

  • Spring Boot 2.7.18 - 核心框架
  • MyBatis-Plus 3.5.5 - ORM 框架
  • JWT - 身份认证
  • MySQL 8.0 - 数据存储
  • DeepSeek API - AI 对话能力
  • Knife4j - API 文档

核心功能

1. 用户认证体系

采用 JWT Token 实现无状态认证:

// JwtUtil.java - Token 生成与验证
@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String generateToken(Long userId, String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
}

2. 用户级 API Key 管理

每个用户独立绑定自己的 DeepSeek API Key,存储于 user.api_key 字段:

// UserServiceImpl.java - 注册时保存用户 API Key
@Override
@Transactional
public void register(RegisterRequest request) {
    User user = new User();
    user.setUsername(request.getUsername());
    user.setPassword(passwordEncoder.encode(request.getPassword()));
    user.setApiKey(request.getApiKey());  // 用户专属 API Key
    user.setStatus(1);
    save(user);
}

3. 流式对话实现

通过 SSE (Server-Sent Events) 实现流式响应:

// AiChatServiceImpl.java - 流式对话核心逻辑
@Override
public void streamChat(ChatRequest request, Long userId, HttpServletResponse response) {
    // 1. 从用户获取 API Key
    User user = userService.getById(userId);
    String apiKey = user.getApiKey();

    // 2. 设置 SSE 响应头
    response.setContentType("text/event-stream");
    response.setCharacterEncoding("UTF-8");

    // 3. 调用 DeepSeek API
    HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
    conn.setRequestProperty("Authorization", "Bearer " + apiKey);

    // 4. 流式转发响应
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(conn.getInputStream()))) {
        String line;
        while ((line = reader.readLine()) != null) {
            writer.write(line + "\n");
            writer.flush();

            // 解析内容保存到数据库
            if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {
                parseAndSaveContent(line, aiMessage);
            }
        }
    }
}

4. 会话与消息管理

  • 会话表 (chat_session): 存储对话元数据
  • 消息表 (chat_message): 存储对话内容,支持 reasoning_content 深度思考

5. 打断功能实现

前端通过 AbortController 中断请求,后端检测连接状态:

// 检测客户端是否断开连接
private boolean isClientConnected(HttpServletResponse response, PrintWriter writer) {
    try {
        writer.write("");
        writer.flush();
        return !writer.checkError();
    } catch (Exception e) {
        return false;
    }
}

项目结构

src/main/java/com/webseek/
├── common/          # 通用工具类
│   ├── JwtUtil.java
│   ├── CurrentUser.java
│   └── Result.java
├── config/          # 配置类
│   ├── WebConfig.java
│   └── JwtInterceptor.java
├── controller/      # 控制器层
│   ├── AuthController.java
│   ├── ChatController.java
│   ├── SessionController.java
│   └── UserController.java
├── service/         # 服务层
│   ├── AiChatService.java
│   ├── UserService.java
│   └── impl/
├── entity/          # 实体类
│   ├── User.java
│   ├── ChatSession.java
│   └── ChatMessage.java
├── dto/             # 数据传输对象
│   ├── request/
│   └── response/
└── mapper/          # MyBatis Mapper

数据库表结构

-- 用户表
CREATE TABLE `user` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `username` VARCHAR(50) NOT NULL UNIQUE,
    `password` VARCHAR(100) NOT NULL,
    `nickname` VARCHAR(50),
    `api_key` VARCHAR(500),        -- DeepSeek API Key
    `status` TINYINT DEFAULT 1,
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 会话表
CREATE TABLE `chat_session` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `session_id` VARCHAR(64) NOT NULL UNIQUE,
    `user_id` BIGINT NOT NULL,
    `title` VARCHAR(200) DEFAULT '新对话',
    `model` VARCHAR(50),
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 消息表
CREATE TABLE `chat_message` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `message_id` VARCHAR(64) NOT NULL UNIQUE,
    `session_id` VARCHAR(64) NOT NULL,
    `user_id` BIGINT NOT NULL,
    `role` VARCHAR(20) NOT NULL,   -- user/assistant
    `content` TEXT,
    `reasoning_content` TEXT,      -- 深度思考内容
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME
);

配置说明

修改 application.yml 中的数据库配置:

spring:
  datasource:
    url: jdbc:mysql://your-host:3306/webseek?useUnicode=true&characterEncoding=utf-8
    username: your-username
    password: your-password

启动方式

# 开发环境
mvn spring-boot:run

# 打包
mvn clean package

# 运行
java -jar target/webseek-backend-1.0.0.jar

API 文档

启动后访问:http://localhost:8090/doc.html

核心设计亮点

  1. 用户级 API Key: 每个用户独立配置,安全隔离
  2. 流式响应: SSE 实现打字机效果,支持实时打断
  3. JWT 认证: 无状态设计,支持水平扩展
  4. 逻辑删除: MyBatis-Plus 自动处理软删除
  5. 深度思考: 支持 DeepSeek-R1 推理模型

源码地址[gitee.com/SongTaoo/re…]

手写一个精简版 Zustand:深入理解 React 状态管理的核心原理

“读源码不是为了造轮子,而是为了更好地驾驭轮子。”
本文将带你从零实现一个功能完整、结构清晰的 Zustand 精简版,并深入剖析其设计哲学与性能优化秘诀。


🌟 为什么是 Zustand?

在 React 生态中,状态管理方案层出不穷。Redux 曾长期占据主流,但其样板代码多、学习曲线陡峭的问题饱受诟病。而 Zustand 凭借极简 API、零模板、自动优化渲染等特性,迅速成为开发者的新宠(GitHub ⭐ 超 30k)。

它的核心优势在于:

  • 无需 Provider,直接 import 使用;
  • 天然支持按需订阅,避免无效重渲染;
  • API 极简,一个 create 搞定一切;
  • 轻量(仅 ~1KB),无依赖。

但你是否想过:Zustand 是如何做到这一切的?

今天,我们就来手写一个精简版 Zustand,揭开它高性能、易用背后的秘密。


🔧 第一步:构建最基础的状态容器

状态管理的核心无非三件事:存、取、改

我们先实现一个最简 Store:

const createStore = (createState) => {
  let state;
  const listeners = new Set();

  // 获取当前状态
  const getState = () => state;

  // 修改状态
  const setState = (partial) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    state = Object.assign({}, state, nextState);
    // 通知所有监听者
    listeners.forEach(listener => listener());
  };

  // 订阅状态变化
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener); // 返回取消订阅函数
  };

  // 初始化状态
  state = createState(setState, getState);

  return { getState, setState, subscribe };
};

关键点解析

  • createState 是用户传入的初始化函数,接收 setget
  • setState 支持传入对象或函数(类似 React 的 useState);
  • 使用 Set 存储监听器,避免重复订阅;
  • 状态变更后,通知所有订阅者 —— 这就是“发布-订阅”模式。

🎣 第二步:让 React 组件能“感知”状态变化

光有 Store 不够,React 组件需要在状态变化时自动重渲染。这就需要一个自定义 Hook。

import { useState, useEffect } from 'react';

const useStore = (api, selector = (state) => state) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe(() => {
      forceUpdate(Math.random()); // 强制更新组件
    });
    return unsubscribe;
  }, []);

  return selector(api.getState());
};

⚠️ 问题来了:这个实现会导致所有使用该 Store 的组件在任意状态变化时都重渲染!这显然违背了 Zustand 的“按需更新”原则。


🚀 第三步:实现“精准订阅”——只在关心的状态变化时更新

Zustand 的核心性能优势在于:组件只订阅自己需要的状态片段

改进思路:

  • 比较 selector 前后的值;
  • 只有当选中的值发生变化时,才触发重渲染。
const useStore = (api, selector) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe((newState, oldState) => {
      const newSelected = selector(newState);
      const oldSelected = selector(oldState);
      // 使用 Object.is 进行严格相等比较(处理 NaN、-0 等边界)
      if (!Object.is(newSelected, oldSelected)) {
        forceUpdate(Math.random());
      }
    });
    return unsubscribe;
  }, [selector]); // 注意:selector 应为稳定函数(通常用 useCallback 包裹)

  return selector(api.getState());
};

💡 为什么有效?

  • CountDisplayselector: state => state.count
  • text 变化时,count 未变 → newSelected === oldSelected不重渲染
  • 完美实现细粒度更新

🏗️ 第四步:封装 create 高阶函数,提供开发者友好的 API

Zustand 的魔法入口是 create。它返回一个 既是 Hook 又是 Store API 对象 的函数。

export const create = (createState) => {
  const api = createStore(createState);

  const useBoundStore = (selector) => {
    return useStore(api, selector);
  };

  // 将 Store 的方法(setState, getState 等)挂载到 Hook 上
  Object.assign(useBoundStore, api);

  return useBoundStore;
};

这样设计的好处

  • 在组件中:const count = useStore(state => state.count)
  • 在非组件中(如工具函数、事件回调):useStore.setState({ count: 10 })
  • 一套 API,两种用法,无缝切换

🧪 完整 Demo:验证局部更新效果

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set(state => ({ count: state.count + 1 })),
  updateText: (text) => set({ text })
}));

// 只订阅 count
const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore(state => state.count);
  const increment = useCounterStore(state => state.increment);
  return <div>Count: {count} <button onClick={increment}>+</button></div>;
};

// 只订阅 text
const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore(state => state.text);
  const updateText = useCounterStore(state => state.updateText);
  return <input value={text} onChange={e => updateText(e.target.value)} />;
};

打开控制台你会发现

  • 点击 “+” 按钮 → 只有 CountDisplay 重新渲染;
  • 修改输入框 → 只有 TextDisplay 重新渲染;
  • 完美隔离,性能拉满!

💡 深度思考:Zustand 为何如此优秀?

  1. 去中心化设计
    无需 Provider 嵌套,状态即模块,天然支持代码分割。
  2. 响应式粒度控制
    通过 selector 实现状态切片订阅,比 Context + useReducer 更高效。
  3. 函数式 + 响应式融合
    set 接收函数支持状态派生,get 支持跨字段计算,灵活又安全。
  4. 极致简洁
    核心代码不足 100 行,却覆盖 90% 场景,体现“少即是多”的哲学。

📌 总结

通过手写 Zustand,我们不仅掌握了:

  • 发布-订阅模式在状态管理中的应用;
  • React 自定义 Hook 与状态同步的技巧;
  • 如何实现精准渲染以提升性能;

更重要的是,理解了优秀库的设计思想简单、专注、可组合

“当你能手写一个库,你就真正拥有了它。”

下次面试被问到 Zustand 原理时,不妨自信地说:
“我不仅用过,我还写过。”

❌