阅读视图

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

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

🔥前端跨域封神解法:Vite Proxy + Express CORS,一篇搞定所有跨域坑!

全网最通俗跨域教程|前端 Vue/React 通用|后端仅 Express|开发 / 生产全覆盖

前言

做前端开发,跨域绝对是新手最崩溃的拦路虎!浏览器同源策略一拦,接口请求直接报错 No 'Access-Control-Allow-Origin',调试半天毫无头绪。

今天直接给你两套绝杀方案,全程只用到 Vite 代理 + Express 后端:✅ 本地开发用 Vite Proxy 代理(零后端改动,秒解决)✅ 线上生产用 Express CORS 配置(标准规范,永久生效)一文吃透,从此跨域再也不是问题!


一、先搞懂:到底什么是跨域?

浏览器同源策略:协议、域名、端口任意一个不同,就是跨域

举个例子:

  • 前端:http://localhost:5173(Vite 默认端口)
  • 后端:http://localhost:3000(Express 服务)端口不同 → 直接跨域,接口被浏览器拦截!

典型跨域报错:No 'Access-Control-Allow-Origin' header is present on the requested resource.


二、方案 1:本地开发神器 ✨ Vite Proxy 代理

核心原理

前端不直接请求后端,交给Vite 开发服务器做中间人转发,绕过浏览器同源限制,纯前端配置,后端零改动

完整配置(Vue / React 二选一)

1. Vue 版本
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  // Vue 编译插件
  plugins: [vue()],
  // 开发服务器配置
  server: {
    // 跨域代理核心配置
    proxy: {
      // 匹配所有 /api 开头的接口
      '/api': {
        target: 'http://localhost:3000', // Express 后端真实地址
        changeOrigin: true, // 🔥 关键:伪装来源,解决跨域
        pathRewrite: {
          '^/api': '' // 路径重写,前端 /api/login → 后端 /login
        }
      }
    }
  }
})
2. React 版本
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  // React 编译插件
  plugins: [react()],
  // 代理配置和 Vue 完全一致!
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
})

关键配置解读

  • target:Express 后端接口真实地址
  • changeOrigin: true:伪装请求来源,让后端认为是同源请求
  • pathRewrite:路径重写,简化前端接口书写

适用场景

仅限本地开发环境上线打包后代理失效,生产环境必须用 CORS!


三、方案 2:生产环境标配 🚀 Express CORS 配置

核心原理

后端在响应头中添加跨域允许规则,明确告诉浏览器:允许这个前端域名访问我的接口。

需要配置的三个核心响应头:

Access-Control-Allow-Origin: 允许的前端域名
Access-Control-Allow-Methods: 允许的请求方法
Access-Control-Allow-Headers: 允许的请求头

完整 Express 配置(直接复制可用)

// 1. 初始化项目:npm init -y
// 2. 安装依赖:npm install express cors
const express = require('express')
const cors = require('cors')
const app = express()

// 解析 JSON 请求体
app.use(express.json())

// 🔥 CORS 核心配置(生产环境必写)
app.use(cors({
  // 允许访问的前端域名(本地开发/线上替换即可)
  origin: 'http://localhost:5173',
  // 允许的请求方式
  methods: ['GET', 'POST'],
  // 允许的请求头
  allowedHeaders: ['Content-Type'],
  // 允许携带Cookie(登录场景必开)
  credentials: true
}))

// 测试接口
app.get('/user', (req, res) => {
  res.send({ 
    code: 200, 
    msg: '请求成功',
    data: { name: '前端开发者' } 
  })
})

// 启动 Express 服务
app.listen(3000, () => {
  console.log('Express 服务启动:http://localhost:3000')
})

极简原生写法(不依赖 cors 包)

如果不想安装第三方包,直接手动设置响应头:

const express = require('express')
const app = express()
app.use(express.json())

// 手动配置 CORS 响应头
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'http://localhost:5173')
  res.header('Access-Control-Allow-Methods', 'GET,POST')
  res.header('Access-Control-Allow-Headers', 'Content-Type')
  res.header('Access-Control-Allow-Credentials', 'true')
  next()
})

// 接口
app.get('/user', (req, res) => {
  res.send({ code: 200, msg: '请求成功' })
})

app.listen(3000)

适用场景

生产环境正式上线Express 专属标准解决方案,全网通用。


四、Proxy vs CORS 到底怎么选?

表格

方案 适用环境 优点 缺点
Vite Proxy 本地开发 零后端改动,配置简单 上线失效
Express CORS 生产环境 标准规范,永久生效 需要后端配置

最佳实践开发用 Proxy,上线用 CORS,两套方案无缝衔接!


五、高频踩坑总结

  1. changeOrigin: true 忘记写 → 跨域依然报错
  2. 路径重写错误 → 接口 404
  3. CORS 域名配置错误 → 线上依然跨域
  4. 开发 / 生产配置混用 → 线上接口异常
  5. 请求方式超出允许范围 → 预检请求失败

结语

跨域根本不是难题,只是没找对方法!Proxy 搞定开发,CORS 搞定生产,照着这篇配置,从此和跨域报错说拜拜~

需要完整 Demo 源码的小伙伴,评论区扣「跨域」直接发你!

💡 关注我,持续输出前端硬核干货,Vue/React/Express 一站式学习!

Map / Set / WeakMap / WeakSet,一次给你讲透

面试中经常被问:你了解 WeakMap / WeakSet 吗?
实际开发中也常有人困惑:我什么时候该用 Map,而不是 Object?Weak 到底弱在哪?

这篇文章,我会从最熟悉的 Object 讲起,一步步到 Map、Set,最后深入 WeakMap 和 WeakSet。

一、从 Object 说起:我们最熟悉,也最容易踩坑

在 JavaScript 里,对象几乎无处不在:

const person = { name: "张三" };
console.log(person.name); // 张三

for (const key in person) {
  console.log(key, person[key]); // name 张三
}

delete person.name;
console.log(person.name); // undefined

我们对 Object 已经非常熟悉了:

  • 可以通过 .[] 访问属性
  • 可以用 for...in 遍历
  • 可以用 delete 删除属性

但 Object 天生就不是为了「做集合」设计的。

一个真实的小坑

假设你想做一个“字典”,key 可以是任意值:

const obj = {};
const a = {};
const b = {};

obj[a] = 'A';
obj[b] = 'B';

console.log(obj); // { "[object Object]": "B" }

你以为是两个 key,实际上:

  • 对象的 key 只能是字符串或 Symbol
  • 非字符串会被隐式转换成字符串

这也是 Map 诞生的原因之一

二、Map:为“键值对集合”而生

可以把 Map 理解成:一个“升级版 Object”,但专门用来存键值对

1. 创建和添加数据

const map = new Map();
map.set('name', '张三');
map.set('phone', 'iPhone');

特点很明确:

  • set(key, value) 添加数据
  • key 可以是任意类型(对象、函数、基本类型)
  • 同一个 key 只会存在一份
map.set('phone', 'Galaxy'); // 覆盖

2. 读取、判断、长度

map.get('phone'); // Galaxy
map.has('phone'); // true
map.size; // 2

3. Map 是可迭代的

这是它和 Object 的一个重要区别

for (const [key, value] of map) {
  console.log(key, value);
}
// name 张三
// phone Galaxy

要仅获取键或值,还有一些方法可供使用

map.keys() // MapIterator {'name', 'phone'}
map.values() // MapIterator {'张三', 'Galaxy'}
map.entries() // MapIterator {'name' => '张三', 'phone' => 'Galaxy'}
map.forEach(item => {})

甚至可以直接展开:

[...map]; // [['name', '张三'],['phone', 'Galaxy']]

4. 删除与清空

map.delete('phone'); // true
// 清空所有
map.clear(); // Map(0) {}

三、WeakMap:真正让人迷惑的地方来了

WeakMap起源于Map,因此它们彼此非常相似。但是,WeakMap 具有很大的不同

弱?弱在哪里?

核心一句话

WeakMap 的 key 是“弱引用”,不会阻止垃圾回收

1. key 只能是对象

const wm = new WeakMap();
wm.set({}, 'data'); // ✅
wm.set('a', 1);    // ❌ TypeError

原因很简单:

  • WeakMap 的设计目标:绑定对象的“附加信息”
  • 如果 key 是基本类型,就谈不上 GC

2. 为什么不能遍历?

想象这样一个场景:

let user = { name: 'John' };
const wm = new WeakMap();
wm.set(user, 'meta');

user = null; // 断开引用

这时候:

  • 垃圾回收 随时可能发生
  • WeakMap 中的数据 可能突然消失

如果还能遍历,那结果就是不稳定的

所以 ES 规范直接规定:

  • ❌ 不可遍历
  • ❌ 没有 size
  • ✅ 只有 get / set / has / delete

3. WeakMap 的真实使用场景

一个非常经典的例子:

const wm = new WeakMap();

function process(obj) {
  if (!wm.has(obj)) {
    wm.set(obj, { count: 0 });
  }
  wm.get(obj).count++;
}
  • 不污染原对象
  • 对象销毁后,数据自动释放
  • 不会内存泄漏

这也是 WeakMap 最大的价值。

四、Set:只关心“值是否存在”

如果说 Map 是 Object 的替代品,

Set 更像是“升级版数组”

1. 成员唯一

const set = new Set();
set.add(1);
set.add(1);
set.add(NaN);
set.add(NaN);

结果:

Set { 1, NaN }

规则总结:

  • 基本类型:值相同 → 只存一个
  • 引用类型:地址相同 → 只存一个
  • NaN 在 Set 中被认为是“相等的”

2. 可遍历

for (const val of set) {}
set.forEach(val => {})

3. 实战:数组去重、交并差集

[...new Set([1,1,2,3])]; // [1,2,3]

Set 在这类场景下,简洁又高效

五、WeakSet:存在,但很低调

WeakSet 和 WeakMap 的理念是一样的:

  • 成员是对象
  • 成员是弱引用
  • 不可遍历
let obj = { a: 1 };
const ws = new WeakSet();
ws.add(obj);

obj = null; // 被 GC

你永远不知道它什么时候“少了一个成员”,

所以:

WeakSet 适合做“对象存在性标记”,而不是数据容器


六、一张表彻底记住它们

类型 key/value 限制 是否可遍历 GC 影响
Object key 只能是字符串 可遍历 强引用
Map key 任意 强引用
WeakMap key 只能是对象 弱引用
Set value 任意 强引用
WeakSet value 只能是对象 弱引用

如何一句话答 WeakMap / WeakSet区别

WeakMap / WeakSet 的核心在于“弱引用 + 不可遍历”,
它们不会阻止垃圾回收,适合存放与对象生命周期绑定的附加数据,用来避免内存泄漏。

如果这篇文章对你有帮助,欢迎点赞、收藏,

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

彻底吃透 Promise:从状态、链式到手写实现,再到 async/await 底层原理

面试必考,源码必问,日常必用 —— Promise 是 JavaScript 异步编程的基石。本文带你完整梳理 Promise 的核心知识,并深入 async/await 的底层实现。

一、为什么需要 Promise?

在 Promise 出现之前,我们靠回调函数处理异步。回调模式有三个致命问题:

  1. 回调地狱:异步任务层层嵌套,代码横向发展(金字塔结构),难以阅读和维护。
  2. 错误处理混乱:每个回调必须单独处理错误,容易遗漏;try/catch 无法捕获异步回调中的异常。
  3. 并发组合困难:并行执行多个任务并在全部完成后执行逻辑,需要手动计数器,极易出错。
  4. 信任问题(控制反转):将回调交给第三方库后,无法保证它会被正确调用(次数、时机、参数等)。

Promise 应运而生,它通过状态机 + 链式调用 + 统一错误处理 + 组合工具,彻底改变了异步编程的体验。


二、Promise 核心概念速览

2.1 三种状态

  • pending(进行中):初始状态。
  • fulfilled(已成功):调用 resolve 后到达此状态,并拥有一个最终 value
  • rejected(已失败):调用 reject 后到达此状态,并拥有一个最终 reason

重要规则:状态一旦定型(settled)就不可再变,且只能从 pending 转换为 fulfilledrejected

2.2 链式调用

thencatchfinally返回一个新 Promise,从而实现链式。

  • then(onFulfilled, onRejected):接收成功/失败回调。返回值决定新 Promise 的状态:

    • 返回普通值 → 新 Promise 用该值 resolve
    • 返回 Promise → 新 Promise 的状态与该 Promise 一致。
    • 抛出异常 → 新 Promise 用该错误 reject
    • 如果 onFulfilledonRejected 不是函数,会发生值穿透(原值直接传递)。
  • catch(onRejected):语法糖 then(undefined, onRejected)

  • finally(onFinally):无论成功失败都会执行,不接收参数,返回值被忽略(除非回调内抛出异常或返回 rejected Promise,则会中断链并传递新错误)。适合做清理工作。

2.3 静态方法一览

方法 行为 典型场景
Promise.all 全部成功才成功,任一失败则立即失败 多个接口数据都成功后才渲染页面
Promise.allSettled 等待所有定型,永不失败;返回结果状态数组 记录所有任务结果,即使部分失败
Promise.race 最快定型的 Promise 胜出(成功或失败) 设置超时计时
Promise.any 最快成功的 Promise 胜出;全部失败才失败 多个备用接口,取最快成功的响应
Promise.resolve 包装值为 resolved Promise 将 thenable 转换为真正 Promise
Promise.reject 包装值为 rejected Promise 快速返回失败

三、面试高频考点:事件循环与微任务

理解微任务(microtask)是写出正确 Promise 代码的前提。

  • 宏任务setTimeoutsetInterval、I/O、UI 渲染。
  • 微任务Promise.then/catch/finallyqueueMicrotaskMutationObserver

执行顺序:当前宏任务 → 所有微任务 → 下一个宏任务

经典例题

console.log(1);
setTimeout(() => console.log(2), 0);
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1,4,3,2

解释:先执行同步代码(1,4),然后清空微任务队列(3),最后执行下一个宏任务(2)。


四、手写一个符合 Promise/A+ 规范的简化版 Promise

面试中常要求手写简易 Promise,核心包含:构造函数、thencatchresolvereject,支持异步与链式调用。

下面是一个符合规范的实现(重点注释):

class MyPromise {
  constructor(executor) {
    this.state = 'pending';   // 'fulfilled' | 'rejected'
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.reason = reason;
      this.onRejectedCallbacks.forEach(fn => fn());
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    // 值穿透处理
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      const fulfilledMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      const rejectedMicrotask = () => {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      if (this.state === 'fulfilled') {
        fulfilledMicrotask();
      } else if (this.state === 'rejected') {
        rejectedMicrotask();
      } else if (this.state === 'pending') {
        this.onFulfilledCallbacks.push(fulfilledMicrotask);
        this.onRejectedCallbacks.push(rejectedMicrotask);
      }
    });

    return promise2;
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    if (value instanceof MyPromise) return value;
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

// 辅助函数:处理 then 返回的 x(可能是普通值、Promise 或 thenable)
function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError('Chaining cycle detected'));
  }
  if (x && (typeof x === 'object' || typeof x === 'function')) {
    let called = false;   // 防止多次调用 resolve/reject
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          y => {
            if (called) return;
            called = true;
            resolvePromise(promise2, y, resolve, reject);
          },
          r => {
            if (called) return;
            called = true;
            reject(r);
          }
        );
      } else {
        resolve(x);
      }
    } catch (err) {
      if (called) return;
      called = true;
      reject(err);
    }
  } else {
    resolve(x);
  }
}

关键点说明

  • 使用 queueMicrotask 模拟原生 Promise 的微任务行为。
  • 支持异步:当状态为 pending 时将回调存入队列,等待 resolve/reject 后执行。
  • 支持链式:then 返回新 Promise,并通过 resolvePromise 解包返回值。
  • 实现值穿透、错误冒泡、循环引用检测。

五、深入理解 async/await 的底层原理

async/await 是 ES2017 引入的语法糖,其底层基于 Promise + 生成器(Generator)

5.1 生成器 + Promise 模拟 async/await

生成器函数可以暂停(yield)和恢复(next),并且可以向外部传递值。利用这一点,我们可以编写一个执行器来自动驱动生成器,每次遇到 yield 就等待 Promise 完成,然后将结果传回生成器继续执行。

以下是一个简化版的执行器 run

function run(generatorFn) {
  const gen = generatorFn();

  function handle(result) {
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(
      value => handle(gen.next(value)),
      error => handle(gen.throw(error))
    );
  }

  try {
    return handle(gen.next());
  } catch (err) {
    return Promise.reject(err);
  }
}

// 使用示例
function fetchData(url) {
  return new Promise(resolve => setTimeout(() => resolve(`数据来自 ${url}`), 1000));
}

const genAsync = function* () {
  const data1 = yield fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = yield fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
};

run(genAsync).then(console.log);

这段代码的行为与 async/await 完全一致:

async function asyncFunc() {
  const data1 = await fetchData('https://api.example.com/user');
  console.log(data1);
  const data2 = await fetchData('https://api.example.com/orders');
  console.log(data2);
  return '完成';
}
asyncFunc().then(console.log);

5.2 编译转换(Babel 视角)

当使用 Babel 将 async/await 编译到 ES5 时,会将其转换为生成器 + 执行器(或 Promise 链)。例如:

// 源代码
async function foo() {
  const a = await bar();
  return a;
}

// Babel 简化输出(类似)
function foo() {
  return _asyncToGenerator(function* () {
    const a = yield bar();
    return a;
  })();
}

其中 _asyncToGenerator 就是一个类似于上面 run 的执行器。

5.3 总结:async/await 的本质

层级 实现机制
最上层 async/await 语法(开发者编写)
转译/编译层 转换为生成器 + 执行器 或 Promise 链
执行层 生成器的 yield 暂停能力 + Promise 的异步通知
底层运行时 微任务(Microtask) + 事件循环

因此,理解 async/await 的关键在于掌握:

  1. Promise 提供了异步结果的标准表示和组合能力。
  2. 生成器 提供了函数执行的可暂停、可恢复能力。
  3. 执行器 将两者粘合,自动处理 Promise 的完成和拒绝,驱动生成器继续执行。

这也解释了为什么 async 函数总是返回 Promise,以及 await 只能出现在 async 函数中——因为生成器模式需要外部执行器驱动,而 async 函数正是这个执行器的容器。


六、高频面试题精选(附解答要点)

1. Promise 有哪几种状态?状态之间如何转换?

  • 三种:pendingfulfilledrejected
  • 转换:pending → fulfilled(调用 resolve),pending → rejected(调用 reject)。状态一旦定型不可逆。

2. then 方法返回的是什么?如何实现链式调用?

  • 返回一个新 Promise。新 Promise 的状态由回调的返回值决定。通过返回新 Promise 实现链式。

3. 什么是 Promise 的“值穿透”?举例。

  • 如果 then 传入非函数,则忽略该参数,原值直接传递下去。
Promise.resolve(42).then(null).then(v => console.log(v)); // 42

4. finally 能改变返回值吗?

  • 不能。返回值被忽略,原 Promise 的值或原因会继续传递。除非 finally 回调抛出异常或返回 rejected Promise,则会传递新错误。

5. Promise.allPromise.allSettled 的区别?

  • all:全部成功才成功,任一失败则立即失败(短路)。
  • allSettled:等待所有定型,总是成功,返回每个结果的状态对象数组。

6. 如何捕获 Promise 链中的错误?

  • 使用链尾的 .catch(),它会捕获链中任何地方抛出的错误(包括 then 回调中抛出的错误)。

7. 简述 Promise 的实现原理(手写简化版)。

  • 状态机 + 回调队列 + then 返回新 Promise + 微任务调度。详见上文实现。

8. 什么是微任务?为什么 Promise 的回调是微任务?

  • 微任务在当前宏任务执行完毕后、下一个宏任务之前执行。Promise 回调设为微任务是为了让异步结果尽快被处理,同时保持顺序可预测。

9. async/await 的底层实现是什么?

  • 基于 Promise 和生成器(Generator)的语法糖。通过执行器自动驱动生成器,每次 yield 一个 Promise,等待完成后恢复执行。

10. 如何将 Node.js 回调风格 API 转换为 Promise?

  • 使用 util.promisify 或手动 new Promise 包装。

七、实战:使用 Promise.race 实现请求超时

function fetchWithTimeout(url, timeoutMs = 5000) {
  const fetchPromise = fetch(url).then(res => res.json());
  const timeoutPromise = new Promise((_, reject) =>
    setTimeout(() => reject(new Error('请求超时')), timeoutMs)
  );
  return Promise.race([fetchPromise, timeoutPromise]);
}

// 使用
fetchWithTimeout('https://api.example.com/data', 3000)
  .then(data => console.log(data))
  .catch(err => console.error(err.message));

注意Promise.race 不会取消未完成的请求,但可以控制超时后的行为。如果需要真正取消请求,可结合 AbortController


八、总结

Promise 的出现统一了 JavaScript 的异步模式,解决了回调地狱、错误处理和组合难的问题。掌握 Promise 是理解现代前端异步编程的基石,而 async/await 则是在 Promise 之上的优雅语法糖,其底层依赖生成器与执行器。希望本文能帮助你彻底吃透 Promise,并在面试和实战中游刃有余。

如果觉得有帮助,欢迎点赞、收藏、评论交流!

从一道前端面试题,聊到朋友做实时通信时的心跳检测

大家好~这篇算是上一篇「前端倒计时不准怎么优化」的延伸。本来只是吃透一道面试题,结果发现同一个思路,居然能用到实时通信里,而且还是朋友做项目时真实踩过的坑,今天用大白话跟大家分享一下。

一、先快速回顾:那道面试题的核心

上一篇我们聊到:用 setInterval 做倒计时为什么不准?因为它是靠 “执行了多少次” 来计时,页面一卡、一切后台,定时器就会偷懒少跑,时间就偏了。

真正靠谱的方案是: 别靠次数,靠时间戳差值 不管定时器怎么延迟,用「目标时间 - 当前时间」算出来的结果永远是准的。

后来我发现,这个思路在WebSocket 心跳检测里简直是一模一样的用法。

二、WebSocket 到底是个啥?(人话版)

平时我们上网,都是浏览器问一句、服务器答一句,叫 HTTP。但像聊天、弹幕、实时数据这种场景,需要服务器主动推消息,HTTP 就不太合适了。

所以会用到 WebSocket:浏览器和服务器建立一条 “长连接”,一直保持通话,服务器有消息就直接推过来。 传感器那边一有新数据,服务器直接推给前端,前端不用傻傻地一遍遍问:“有新数据吗?有新数据吗?”

这就是实时通信

聊天、弹幕、股票、传感器数据,基本都靠它。

而且它不是插件、不是库,是浏览器原生自带的 API,直接写就能用。

三、用上 WebSocket 就万事大吉了?并没有

以为连上就完事,结果踩了一堆坑:

1. 连接会莫名其妙断掉

  • 网络假死

    • 连接表面还在,实际已经断了(WiFi 切换、路由器重启、弱网),WebSocket 不会自动感知。
  • 服务器踢人

    • 网关会自动断开 “长时间不说话” 的空闲连接,心跳就是用来 “刷存在感”。
  • 及时发现异常

    • 有时候断了前端都不知道,导致消息发不出去、用户体验极差。

2. 不知道连接到底还活不活着

网络有时候会 “假死”:看着连着,其实早就断了,前端还在傻傻等数据。

3. 断了之后不能自动重连

总不能让用户手动刷新页面吧?


四、解决办法:心跳检测 + 断线重连

这时候,最开始那道倒计时面试题的思路就用上了:

什么是心跳检测?

就像两个人打电话,每隔一会儿说一句:“我还在哦。”对方回:“我也在。”

  • 前端每隔几十秒发一个小包(心跳包)
  • 服务器收到后回复一下
  • 一段时间没回复,就认为连接挂了

先明确:WebSocket 自带心跳吗?

结论:不带!必须开发者自己写!

WebSocket 只负责建立连接、收发数据,心跳、保活、断线重连、超时判断,全都要自己写

这里刚好用到面试题的技巧:

不用 “定时器跑了多少次” 来判断超时,而是用:当前时间 - 最后一次收到回复的时间只要差值超过某个时间,就判定断开,直接重连。

完美复用了倒计时那套 “用时间差值,不靠次数” 的思想。

额外一个小细节:

浏览器切到后台、锁屏或休眠时,WebSocket 可能被系统冻结,表面不断开实则已失效。

可以在页面切回前台时,主动检查一次连接状态:

js

document.addEventListener('visibilitychange', () => {
  if (!document.hidden && ws) {
    // 回到前台,检查是否还在线
    if (!ws.isConnected) {
      ws.reconnect();
    }
  }
});

五、WebSocket 上线后还会遇到哪些难点?(深度拓展)

WebSocket 只解决了 “实时推送” 的基础问题,真正到生产环境落地,还会遇到一大堆工程化和稳定性的难点,我按开发→上线→运维的顺序,用大白话给大家拆解开,小白也能看懂:

1️⃣ 数据可靠性痛点

痛点 1:消息会丢失

场景:网络闪断瞬间,正在传输的传感器数据直接消失,用户看不到完整数据。详细解决方法

  1. 消息确认机制(ACK)

    • 前端发消息时,给每条消息加唯一 msgId,并启动一个超时定时器(比如 5 秒)。
    • 服务端收到后,必须回复 { type: 'ack', msgId: 'xxx' } 确认。
    • 前端如果在超时时间内没收到 ACK,就重新发送这条消息(最多重发 3 次,避免无限循环)。

js

// 封装一个完整的 WebSocket 客户端(带心跳 + 重连)
class WebSocketClient {
  // 构造函数:初始化所有配置
  constructor(url) {
    this.url = url; // WebSocket 服务端地址
    this.ws = null; // 存放 WebSocket 实例
    this.isConnected = false; // 标记是否连接成功

    // ==================== 心跳配置 ====================
    // 心跳发送间隔:3秒发一次
    this.heartBeatInterval = 3000;
    // 记录最后一次收到心跳回复的时间(核心:用时间戳判断)
    this.lastHeartBeatAckTime = Date.now();
    // 心跳定时器
    this.heartBeatTimer = null;

    // ==================== 重连配置 ====================
    this.reconnectTimer = null; // 重连定时器
    this.reconnectDelay = 3000; // 断开后 3 秒重连
  }

  // 初始化 WebSocket 连接
  connect() {
    this.ws = new WebSocket(this.url);

    // ==================== 连接成功触发 ====================
    this.ws.onopen = () => {
      console.log("✅ WebSocket 连接成功");
      this.isConnected = true;
      this.startHeartBeat(); // 连接成功 → 立刻启动心跳
    };

    // ==================== 收到服务端消息 ====================
    this.ws.onmessage = (evt) => {
      const data = JSON.parse(evt.data);

      // 如果是心跳响应 → 更新最后收到心跳的时间
      if (data.type === "heartbeat_ack") {
        this.lastHeartBeatAckTime = Date.now();
        return;
      }

      // 普通业务数据(比如传感器/实时消息)
      console.log("📡 收到实时数据:", data);
    };

    // ==================== 连接断开触发 ====================
    this.ws.onclose = () => {
      console.log("🔌 连接断开,准备重连...");
      this.isConnected = false;
      this.stopHeartBeat(); // 断开 → 停止心跳
      this.reconnect(); // 自动重连
    };

    // ==================== 连接报错触发 ====================
    this.ws.onerror = (err) => {
      console.error("❌ 连接异常", err);
    };
  }

  // ==================== 心跳检测核心方法 ====================
  startHeartBeat() {
    this.heartBeatTimer = setInterval(() => {
      // 向服务端发送心跳包
      this.ws.send(JSON.stringify({ type: "heartbeat" }));

      // ==================== 重点:用时间差判断是否超时 ====================
      // 和倒计时面试题同一个思路:不用计数,用时间戳差值
      const now = Date.now();
      // 超过 2 个心跳周期没回复 → 判断断开
      if (now - this.lastHeartBeatAckTime > this.heartBeatInterval * 2) {
        console.log("💀 心跳超时,开始重连");
        this.close(); // 关闭旧连接
        this.reconnect(); // 触发重连
      }
    }, this.heartBeatInterval);
  }

  // 停止心跳
  stopHeartBeat() {
    clearInterval(this.heartBeatTimer);
  }

  // ==================== 断线自动重连 ====================
  reconnect() {
    // 防止重复重连
    if (this.reconnectTimer) return;
    this.reconnectTimer = setTimeout(() => {
      this.connect(); // 重新创建连接
      this.reconnectTimer = null;
    }, this.reconnectDelay);
  }

  // 关闭连接 + 清理心跳
  close() {
    this.ws?.close();
    this.stopHeartBeat();
  }
}

// ==================== 使用方式 ====================
// 创建客户端实例
const ws = new WebSocketClient("ws://localhost:8080/sensor");
// 启动连接
ws.connect();

我们把整个流程拆成「正常运行」和「异常断连」两个场景,用大白话描述:

场景 1:正常连接 & 心跳保活

  1. 建立连接:前端调用 connect(),和服务端建立 WebSocket 连接,连接成功后触发 onopen

  2. 启动心跳:连接成功后立刻调用 startHeartBeat(),开启一个每 3 秒执行一次的定时器。

  3. 发送心跳:定时器每 3 秒向服务端发送 {type: "heartbeat"} 心跳包。

  4. 服务端响应:服务端收到心跳后,回复 {type: "heartbeat_ack"} 心跳响应包。

  5. 更新时间戳:前端收到 heartbeat_ack 后,立刻更新 lastHeartBeatAckTime = 当前时间

  6. 超时判断:每次发心跳时,都会计算「当前时间 - 最后心跳响应时间」:

    • 如果差值 ≤ 6 秒(2 个心跳周期):说明连接正常,继续循环。
    • 如果差值 > 6 秒:说明服务端没回应,判定连接假死

场景 2:连接异常 & 自动重连

  1. 触发超时:连续 2 个心跳周期(6 秒)没收到 heartbeat_ack,判定连接断开。

  2. 关闭旧连接:调用 close() 主动关闭当前无效连接,同时停止心跳定时器。

  3. 触发重连:调用 reconnect(),等待 3 秒后(避免重连风暴)重新执行 connect()

  4. 重新连接:新的 connect() 尝试和服务端建立连接:

    • 连接成功:回到「正常连接 & 心跳保活」流程,继续发心跳。
    • 连接失败:触发 onclose,再次进入重连逻辑,直到连接恢复。

后续场景:

离线消息缓存

-   服务端给每个连接维护一个「待推送消息队列」,当客户端断开时,消息暂存队列。
-   客户端重连成功后,服务端先把队列里的未读消息全部推送过去,再推送新消息。

痛点 2:消息乱序 / 重复

场景:重连后消息顺序打乱,或者同一条消息被重复推送,导致页面展示错误。详细解决方法

  1. 消息序号 + 时间戳

    • 服务端推送消息时,必须带上自增 seq(序号)和 timestamp(时间戳)。
    • 前端维护一个 lastSeq 变量,只处理 seq > lastSeq 的消息,保证顺序。
  • lastSeq 是前端维护的一个变量,用来记录最后一次成功处理的消息序号

    • 初始值一般设为 0(表示还没处理过任何消息)
    • 每次处理完一条新消息,就把 lastSeq 更新为这条消息的序号 data.seq
    • 作用:记住 “我已经处理到哪条消息了”
  • if (data.seq > lastSeq)消息去重 + 保证顺序的核心判断逻辑:

    • data.seq:服务端推送过来的当前消息的序号(自增,比如 1、2、3、4...)

    • 条件 data.seq > lastSeq

      • ✅ 如果当前消息序号 大于 上次处理的序号 → 说明是新消息、顺序正确,可以渲染 / 处理
      • ❌ 如果当前消息序号 小于等于 上次处理的序号 → 说明是旧消息 / 重复消息 / 乱序消息,直接丢弃,不处理

    js

    let lastSeq = 0;
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (data.seq > lastSeq) {
        renderData(data); // 只渲染顺序正确的消息
        lastSeq = data.seq;
      }
    };
    
  1. 去重机制

    • 前端维护一个 Set 存储已处理的 msgId,收到消息先判断是否存在,存在则直接丢弃。

    js

    const processedMsgIds = new Set();
    ws.onmessage = (e) => {
      const data = JSON.parse(e.data);
      if (processedMsgIds.has(data.msgId)) return;
      renderData(data);
      processedMsgIds.add(data.msgId);
    };
    

痛点 3:大数据传不了

场景:WebSocket 单条消息有大小限制(通常 64KB 左右),传大文件 / 海量传感器数据会直接失败。详细解决方法

  1. 分片传输 + 前端重组

    • 把大数据拆成固定大小的分片(比如 16KB / 片),每个分片带上 chunkId(分片序号)、totalChunks(总分片数)、msgId(所属消息 ID)。
    • 前端收到所有分片后,按 chunkId 顺序拼接成完整数据。

    js

    // 前端分片重组示例
    const chunkMap = new Map(); // key: msgId, value: { chunks: [], total: number }
    ws.onmessage = (e) => {
      const chunk = JSON.parse(e.data);
      if (!chunkMap.has(chunk.msgId)) {
        chunkMap.set(chunk.msgId, {
            chunks: new Array(chunk.totalChunks), total: chunk.totalChunks 
        });
      }
      const entry = chunkMap.get(chunk.msgId);
      entry.chunks[chunk.chunkId] = chunk.data;
      
      // 所有分片都收到了,开始重组
      if (entry.chunks.every(c => c != null)) {
        const fullData = entry.chunks.join('');
        renderData(fullData);
        chunkMap.delete(chunk.msgId);
      }
    };
    

为什么 every(c => c!= null) 能代表接收完毕?

  • 这是 “前端分片重组” 的约定:
    • 背后有一个硬性前提(这是大文件上传 / 大消息传输的通用标准):

    • 服务端(后端)在发送分片时,必须按顺序编号!

为什么不会有空分片的情况?

1. 后端不会发空包
  • 在 “分片传输” 场景下,空的分片(null)是没有业务意义的。

    • 一个完整的大文件,被切分成了 10 块,每一块都有内容。
    • 后端不可能只发了 9 块,第 10 块发一个 null
    • 规则:每一个 chunkId 对应的,必须是一段真实的数据。
2. null 代表的是 “未收到”,不是 “空数据”

在这段代码里:

  • entry.chunks = new Array(chunk.totalChunks)

    • 这行先创建了一个空数组,长度是总片数。
    • 此时数组里全是 empty(空槽),但这还不是 null
  • 当收到第 0 片时,entry.chunks[0] = chunk.data

    • 这一格被填满了。
  • 如果网络丢包了:比如第 2 片没收到。

    • entry.chunks[2] 就永远是 empty(或者被你初始化为 null)。
    • 此时 every(c => c!= null) 就会返回 false
    • 代码就不会拼接,会继续等待,直到补全了第 2 片。

如果网络丢包了,前端必须要做的处理

你不能让它无限等下去,通常要加这些机制:

  • 超时机制:给每个分片集合设置一个等待超时时间(比如 30s),超时后主动抛出错误或重试。
  • 重传机制:检测到丢包后,向服务端请求重传丢失的分片。
  • 兜底策略:如果多次重传仍失败,给用户提示 “网络不稳定,部分内容加载失败”,而不是一直转圈。
  • 进度反馈:告诉用户当前已收到多少分片、还在等待哪几片,避免用户以为页面卡死。

js

// 超时后处理
if (isTimeout(entry)) { 
    if (retryCount < MAX_RETRY) { 
        retryCount++; requestMissingChunks(entry); // 重传丢失的分片 
    } else {
        showError("加载失败,请检查网络"); } return; 
    }
}

2️⃣ 业务与性能痛点

痛点 1:百万级连接扛不住

场景:上千个传感器同时连接,服务器内存暴涨、连接数过载,甚至崩溃。详细解决方法

  1. 服务端高性能框架

    • 用 Netty(Java)、Node.js Cluster、Go 等高性能框架,利用多核心 CPU 处理连接,避免单线程瓶颈。
    • 开启连接复用、内存池优化,减少每个连接的内存占用。
  2. 负载均衡 + 水平扩展

    • 用 Nginx 或云服务商负载均衡器,把连接分发到多台服务器。
    • 服务器之间通过共享存储(如 Redis)同步用户连接状态,实现水平扩容。

痛点 2:不知道消息推送给谁

场景:多个传感器分组、不同用户看不同设备数据,推送混乱、浪费资源。详细解决方法

  1. Pub/Sub(发布 - 订阅)模式

    • 把每个传感器 / 用户组抽象成一个频道(Channel)
    • 客户端连接后,订阅自己需要的频道(比如 sensor:temp:room1)。
    • 服务端只往有订阅者的频道推送消息,避免无效推送。
    • 可以用 Redis Pub/Sub、MQTT、Kafka 等现成组件实现。

    js

    // 前端订阅示例
    ws.send(JSON.stringify({ type: 'subscribe', channel: 'sensor:temp:room1' }));
    

痛点 3:前端页面卡顿

场景:传感器每秒推 100 条数据,前端频繁渲染 DOM 导致页面卡死、崩溃。详细解决方法

  1. Web Worker 处理数据

    • 把数据解析、计算逻辑放到 Web Worker 里,不和主线程抢资源,避免阻塞 UI 渲染。

    js

    // main.js-页面主线程
    // 主线程(页面)只负责渲染和收消息,所有耗时计算都扔给 Web Worker 去做,不让页面卡顿
    // 1. 创建一个后台工作线程
    const worker = new Worker('data-worker.js');
    
    // 2. 监听 Worker 算完后发回来的结果
    worker.onmessage = (e) => {
      renderData(e.data); // 只做一件事:渲染页面
    };
    
    // 3.  websocket 收到数据 → 直接扔给 Worker,不自己算
    ws.onmessage = (e) => {
      worker.postMessage(e.data); 
    };
    
    // data-worker.js -后台独立线程,专门算东西,不影响页面
    // 监听主线程发来的数据
    self.onmessage = (e) => {
      // 这里做耗时计算!!!
      const processedData = parseAndCalculate(e.data); 
    
      // 算完 → 发回给主线程
      self.postMessage(processedData);
    };
    
  2. 节流渲染

    • setTimeoutrequestAnimationFrame 做节流,比如 100ms 内只渲染一次最新数据。

    js

    let lastRenderTime = 0;
    let pendingData = null;
    ws.onmessage = (e) => {
      pendingData = JSON.parse(e.data);
      requestAnimationFrame(() => {
        const now = performance.now();
        if (now - lastRenderTime > 100) {
          renderData(pendingData);
          lastRenderTime = now;
        }
      });
    };
    

3️⃣ 安全与合规痛点

痛点 1:谁都能连,数据不安全

场景:未做身份验证,任何人都能连接窃取传感器数据。

详细解决方法

  1. Token 身份验证

    • WebSocket 握手时,在 URL 或 Header 里带上 Token(比如 wss://xxx.com?token=xxx)。
    • 服务端先校验 Token 有效性,无效则直接拒绝连接。

    js

    // 前端连接示例
    const ws = new WebSocket(
    `wss://xxx.com/sensor?token=${localStorage.getItem('token')}`
    );
    
  2. 细粒度权限控制

    • 服务端根据 Token 对应用户的权限,只允许订阅 / 发送自己有权限的设备数据,比如普通用户只能看自己的传感器,管理员才能看所有。

痛点 2:数据会被窃听、篡改

场景:明文传输时,数据在网络中可能被截获、修改。

详细解决方法

  1. 必须用 wss:// 协议

    • wss:// 是基于 TLS 加密的 WebSocket,和 https:// 一样,数据在传输过程中会被加密,防止窃听和篡改。
    • 绝对不要在生产环境用 ws://(明文)。
  2. 敏感数据额外加密

    • 对特别敏感的数据(比如用户隐私、设备核心参数),在发送前用 AES 等对称加密算法加密,接收后再解密,进一步提升安全性。

痛点 3:恶意攻击耗尽服务器资源

场景:攻击者建立大量虚假连接,或疯狂发送消息,导致正常设备无法接入。

详细解决方法

  1. 连接 / 频率限制

    • 限制单个 IP 最多只能建立 10 个连接,超过则拒绝。
    • 限制单个连接每秒最多发送 10 条消息,超过则断开连接。
  2. 消息大小限制

    • 服务端设置单条消息最大长度(比如 64KB),超过则直接丢弃,防止超大消息占用带宽。

4️⃣ 调试与监控痛点

痛点 1:出问题找不到原因

场景:断连、丢消息等问题很难复现,日志分散,排查效率极低。

详细解决方法

  1. 全链路追踪

    • 接入 OpenTelemetry 等工具,给每个连接、每条消息生成唯一 Trace ID,记录从客户端→服务端→数据库的完整调用链路。
    • 出问题时,通过 Trace ID 就能快速定位是哪一步出了问题。
  2. 消息日志留存

    • 服务端记录所有消息的收发日志(包含 msgIdseq、时间戳、发送 / 接收方),方便回溯问题发生时的上下文。

痛点 2:不知道服务运行状态

场景:服务器连接数、消息延迟、断连率等指标无监控,异常时无法及时发现。

详细解决方法

  1. 核心指标监控

    • 用 Prometheus + Grafana 监控以下指标:

      • 在线连接数
      • 消息吞吐量(条 / 秒)
      • 平均消息延迟(毫秒)
      • 断连率(断开连接数 / 总连接数)
      • 消息丢失率
  2. 告警规则配置

    • 当连接数突增 50%、延迟超过 200ms、断连率超过 10% 时,自动通过钉钉 / 企业微信 / 邮件通知运维人员。

痛点 3:环境不兼容,功能用不了

场景:旧浏览器(如 IE11)、特殊网络(如企业防火墙)不支持 WebSocket,用户无法使用功能。

详细解决方法

  1. 自动降级方案

    • 前端先检测浏览器是否支持 WebSocket,不支持则自动切换为 长轮询(Long Polling)

      js

      if (window.WebSocket) {
        // 用WebSocket
      } else {
        // 用长轮询:前端发请求,服务端hold住请求,有新数据时再返回,然后前端立刻发起下一次请求
        function longPoll() {
          fetch('/api/long-poll')
            .then(res => res.json())
            .then(data => {
              renderData(data);
              longPoll(); // 立刻发起下一次请求
            });
        }
        longPoll();
      }
      
  2. 友好 Fallback UI

    • 降级时给用户提示:「当前环境不支持实时通信,已切换为普通模式,数据每 30 秒自动刷新」,避免用户困惑。

六、最后聊聊

从一道倒计时面试题,意外挖到 WebSocket 心跳的通用思路,还挺有意思的。

很多时候我们觉得实时通信复杂,其实拆开看,无非就是:保证连接活着、保证消息不丢、保证页面不卡。

真正上线后你会发现,WebSocket 本身不难,难的是各种网络异常、弱网、断连、重复消息、卡顿……能把这些 “边角情况” 都兜住,才算一个能用在生产里的稳定方案。

如果你也在做聊天、大屏、传感器数据这类实时需求,欢迎在评论区说说你遇到过什么奇奇怪怪的坑,我们一起交流~

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

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

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

1. 追踪滚动位置

手动实现

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

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

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

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

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

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

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

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

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

用 useScroll

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

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

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

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

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

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

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

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

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

2. 弹窗滚动锁定

手动实现

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

import { useEffect, useState } from "react";

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

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

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

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

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

用 useScrollLock

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

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

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

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

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

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

3. 平滑滚动到指定区域

手动实现

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

import { useRef } from "react";

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

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

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

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

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

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

用 useScrollIntoView

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

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

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

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

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

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

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

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

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

4. 吸顶检测

手动实现

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

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

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

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

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

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

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

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

用 useSticky

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

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

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

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

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

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

用 useElementVisibility

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

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

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

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

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

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

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

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

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

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

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

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

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

安装

npm i @reactuses/core

相关 Hook


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

如果想转 AI 全栈?推荐你学一下 Langchain!

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

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

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

image.png

上个月月底,我去参加了一场在深圳举办的线下聚会,现场人很多,几乎称得上爆满,分享具体讲了什么我其实没有认真听完,但有一个现象让我印象特别深。

我发现,现场已经有很多并非技术出身的人在真实地使用 AI 做开发,有人是产品经理,有人甚至没有完整的软件工程背景,但他们一样能借助 Claude CodeCursor 这类 AI 编辑器,把一个产品从想法推进到可运行的形态。

只要你真正用过这类工具,你就会知道它们强在哪里,很多时候你不必先把所有代码写完,只要把问题、目标和约束说清楚,模型就能替你完成相当大一部分工作,它不光是在替你补几行代码,更是在把你的想法翻译成可执行的过程。

这件事带来的冲击其实很直接,不是只有程序员才能做产品了,而是谁更会拆问题、谁更会组织上下文、谁更会调度 AI,谁就更有机会把事情做成。

所以,真正需要警惕的从来不是 AI 会不会写代码,而是你是否还停留在只会发一个 chat.completions 请求、然后等它吐一段文本的阶段,因为当 AI 开始参与真实任务时,竞争点已经不再只是会不会调模型,而是你能不能把模型接进系统、接进流程、接进业务,最后让它稳定地把事做完。

也正因为如此,这套文档不会停留在教你调用一下 LLM API 这一层,它想解决的是更往前一步的问题,当 AI 不再只是聊天,而是真正进入你的产品、流程和工程系统里时,你到底该怎么设计它、约束它、组织它、编排它。

从会调模型到能改整条 Agent 链路

理想状态大概是,你不再满足于发完请求就收一段文本,而是能把一条真正可执行的 Agent 链路说清楚,别人问起来,你也知道该动哪一层、从哪下手改。

这里不会拿概念填空来凑篇幅,那些词你多半已经见过。更值得花时间的是落地之后一定会撞上的事,比如上下文该留什么、该砍什么,模型才既记得住关键信息,又不会被历史拖垮。工具怎么写、Function Call 怎么接,才能少空转、少胡编,多把事办完。结构化输出怎么定,业务里才能当真数据用,而不是靠正则和运气硬接。

再往后,中间件、护栏、运行时、上下文工程各自兜的是哪一类坑,MCP 这类协议又该摆在协作架构的哪一层。人机协同、多 AgentSubagentsHandoffsSkillsRouter、自定义工作流,听起来多,其实都是在不同复杂度下选一条路。至于 CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 这些名字,背下来没多大用,有用的是它们背后控制流怎么画、推理预算该多给还是该省。

章节一路跟下来,术语和框架名自然会熟,但更值得带走的是一种手感。某类任务该用简单的 Agent 循环还是上图式编排,某段流程要不要上人审、要不要拆角色,某一步老是失败时,该补护栏、补记忆、补工具描述,还是干脆换一套推理策略。能分清这些,比多记十个 API 名字实在得多。

真正花时间的是把系统搭稳

网上讲 AI 开发的内容已经很多,常见的却两头偏,一头概念讲得热闹,回到工程里不知道该动哪只手,另一头 demo 复制粘贴能跑,一进真实业务就开始散。

第一次把结果跑出来的时候,你往往还觉得挺顺。你很快会发现,真正难的从来不是让它第一次跑起来,而是:

  • 为什么这个 Agent 一到复杂任务就开始乱
  • 为什么多轮之后上下文越来越脏
  • 为什么工具明明接了,模型还是不会正确调用
  • 为什么结构化输出看起来像 JSON,实际上却根本不稳定
  • 为什么接了很多能力,系统却越来越难控、越来越难测、越来越难上线

这套文档想把这一串问号拆开来看。重点不单是让模型答得更聪明,而是让你看清一个能进生产环境的系统底下有几层、每层在扛什么,出事该往哪一层摸,而不是遇事就把锅甩给模型不够聪明。

如何学习

按章节顺序读就行,不是要你迷信目录,而是后面的例子会默认你已经看过前面的概念,跳太狠容易半路卡住。

开头一大段都在打基础,裸调模型哪里别扭、LangChain 在补什么、Function Call、消息结构、工具怎么接、先跑一个最简单的 Agent、再加上会话记忆和结构化输出。拆开看是很多篇,合起来就是在说一件事,模型是怎么被接进一条可执行的链路里的。

再往后会硬一些,主要对付"能跑"和"敢上线"之间的差距,中间件、护栏、运行时、上下文工程、MCP、人机协同、多 Agent,以及 SubagentsHandoffsSkillsRouter、自定义工作流之类。名字多,你不用全记住,先有个印象,知道这些多半是在管权限、管边界、管出事以后谁来兜底。

后面才轮到规划、反思、试探、回退这类话题。CoTToTGoTReActPlan-and-ExecuteReflexionSelf-CriticLATS 当几种不同的走法看就好,定义背了也没多大用。有用的是下面这些判断,心里过一遍比抄名词强:

  • 什么场景下值得多给一点推理预算
  • 什么场景下应该尽快落工具、少走内耗
  • 什么任务适合先规划后执行
  • 什么任务反而应该边做边修正
  • 什么情况下多想一步是收益,什么情况下只是成本

快收尾的时候会把长期记忆和 harness 拉出来,把执行、状态、持久化、审计、可观测性这些零散提过的东西并到一块,方便你对照真实环境里一般长什么样。

20260329233412

整体就是这样,先把基础概念和常见拼法摸熟,再啃工程和协作里那些让人心里发虚的部分,最后在控制流和收尾方式上收个口。

适合谁、怎么读

你若是写 React、做业务、跟需求,模型 API 也碰过,却越来越觉得卡不在页面上,而在模型怎么接、工具怎么配、多步任务怎么串,这一路的写法就是按这个感觉排的。

做过聊天框、demo,想再往"能办事"那边挪一步,也会对上号。别人做出来的像助手,自己的还在一问一答里打转,这类落差在这里会当成工程问题拆,而不是甩一句模型不够聪明。

还有一种情况,文章东一篇西一篇看过,记忆、工具调用、Agent 都见过词,就是拼不出一张图。按章节往下翻,多半能把那些散点接回一条线。

读法上可以松一点,不必一次啃完。过完一章,想想自己项目里有没有同款糟心事,有的话最小改动可以先动哪一步。理论不用第一遍就全吃透,能慢慢把问题和章节里的招对上,就已经在读对路了。

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

你有没有发现一个现象:

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

写到最后:

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

这篇文章只讲一件事:

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

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

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

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

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

于是有了 useEffect

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

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

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

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

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

这是 React 新手最大的误区。

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

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

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

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

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

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

滥用 useEffect 的三大灾难

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

典型灾难链:

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

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

这 4 种场景,绝对别用 useEffect

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

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

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

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

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

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

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

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

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

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

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

🎯 useEffect 的唯一合法使用场景

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

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

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

结尾

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

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

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

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

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

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

🔢 前言

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

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

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

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

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

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

1. 题目描述

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

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

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

2. 示例演示

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

3. 难度级别

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

二、解题思路大剖析

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

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

核心步骤:

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

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

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

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

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

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

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

  • 第 1 轮(个位):

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

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

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

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

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

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

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

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

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

基础解法的优缺点:

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

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

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

优化点:

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

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

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

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

优化解法的特点:

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

三、总结

1. 核心逻辑

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

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

2. 最后

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

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

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

目的

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

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

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

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

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

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

image.png

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

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

image.png

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

image.png

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

image.png

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

image.png

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

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

Decorator Pattern(装饰器模式)

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

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

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

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

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

使用方式如下:

image.png

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

image.png

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

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

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

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

其他案例

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

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

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

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

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

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

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

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

image.png

总结

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

前端预检请求是什么?

前言

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

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

知其然知其所以然

一、浏览器的‌同源策略

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

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

例如:

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

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

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

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

二、解决跨域的方法

1. CORS(跨域资源共享)

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

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

2. JSONP(JSON with Padding)

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

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

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

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

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

4. WebSocket

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

5. postMessage API

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

6. document.domain + iframe

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

7. window.name + iframe

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

前端预检请求是什么?

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

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

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

何时会触发预检请求?

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

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

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

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

预检请求的内容

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

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

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

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

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

为什么需要预检请求?

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

如何优化预检请求?

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

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

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

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

本文思维导图

前言 (1).png

写在最后

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

与你相逢,我很开心。

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

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

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 退出历史舞台吗?

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

❌