阅读视图

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

别再手动调 Prompt 了!这款开源神器让 AI 输出质量提升 300%,支持 Claude、GPT、Gemini,还免费开源!

你是不是也这样?

  • 写 10 版提示词,AI 还是答非所问
  • 想让小模型做角色扮演,结果它“人格分裂”
  • 提取知识图谱,输出格式天天变
  • 本地部署 Ollama,但 Web 工具连不上……

而今天,我要介绍的这个 GitHub 23k Star 的开源神器,能一键优化你的提示词,让 GPT-4、Claude、Gemini 甚至 7B 小模型都稳定输出高质量结果

更惊人的是——
纯前端,数据不上传
支持 Chrome 插件,边写边优化
内置文生图(T2I)和图生图(I2I)
可私有化部署,还能接入 Claude Desktop
完全免费 + 开源 + 中文友好

它就是 —— Prompt Optimizer(提示词优化器)


一、为什么你需要一个“提示词优化器”?

大模型很强大,但提示词的质量决定输出上限
普通用户写:“写一首诗” → AI 随便糊弄。
高手写:“以‘春夜细雨’为主题,七言绝句,押平水韵,意象含柳、灯、纸伞,情感含蓄哀而不伤” → 出精品。

但没人天生会写好 Prompt。
Prompt Optimizer 能自动帮你把模糊需求转化为精准指令,并支持多轮迭代优化。

它不是“替代你思考”,而是“放大你的意图”。


二、三大核心场景,效果炸裂

场景 1:激发小模型潜力(降本增效)

在成本敏感或隐私要求高的场景(如本地 Ollama),结构化提示词能让 Qwen2.5-7B 稳定扮演“--”,对话一致性提升 300%。

无需微调,仅靠提示词工程,小模型也能“演得像”。

场景 2:保障生产环境稳定性

当你要程序化解析文本(如提取知识图谱),高质量提示词能让 输出格式 100% 符合 JSON Schema,避免后端解析崩溃。

降低对模型智能度的要求,经济的小模型也能扛生产。

场景 3:辅助创意探索

从“写首诗”到“指定意象+情感+格律”,工具帮你把模糊灵感细化为可执行指令,与 AI 共创独一无二的作品。


三、五大隐藏能力,远超想象

能力 说明
双模式优化 同时优化系统提示词(System Prompt)和用户提示词(User Prompt)
多模型集成 支持 OpenAI、Gemini、DeepSeek、智谱、SiliconFlow、Ollama(兼容 OpenAI 接口)
图像生成 内置 T2I(文生图)和 I2I(图生图),支持 Gemini、Seedream
高级测试模式 上下文变量管理、多轮会话测试、Function Calling 调试
纯客户端架构 所有数据直连 AI 服务商,不经过任何中间服务器

甚至支持 MCP 协议,可直接在 Claude Desktop 中调用优化服务!


四、四种使用方式,总有一款适合你

在线体验(最快)

直接访问:prompt.always200.com

  • 无需安装
  • 数据仅存浏览器本地
  • 支持所有核心功能

Chrome 插件(最方便)

  • 安装地址:Chrome 商店
  • 点击图标即可打开优化器
  • 边写提示词边优化

桌面应用(最稳定)

  • 下载地址:GitHub Releases
  • 无跨域限制(可直连本地 Ollama)
  • 自动更新
  • 独立运行,性能更强

Docker 私有化部署(最安全)

docker run -d -p 8081:80 \
  -e VITE_OPENAI_API_KEY=your_key \
  -e ACCESS_PASSWORD=your_password \
  --name prompt-optimizer \
  linshen/prompt-optimizer
  • 支持访问密码保护
  • 国内镜像加速:registry.cn-guangzhou.aliyuncs.com/prompt-optimizer/prompt-optimizer

五、为什么它能火遍全球?

  • 真正解决痛点:不是玩具,而是生产级工具
  • 极致用户体验:Web + 桌面 + 插件 + Docker 全覆盖
  • 开源精神:AGPL-3.0 协议,可商用但需开源衍生作品
  • 社区驱动:23k+ Star,2.8k+ Fork,23 位贡献者

项目文档极其完善:Vercel 部署指南MCP 使用说明


六、如何开始?30 秒上手

  1. 打开 prompt.always200.com
  2. 在左侧输入你的原始提示词(如“写一首关于春天的诗”)
  3. 点击“优化提示词”
  4. 查看优化前后对比,一键复制!

首次使用?点击右上角⚙️配置你的 API 密钥(OpenAI/Gemini/DeepSeek 等)


结语:好工具,值得被更多人看见

在这个 AI 爆发的时代,
不会写 Prompt 的人,正在被会用工具的人甩开

而 Prompt Optimizer 的出现,
让普通人也能写出接近专家级的提示词。

它不炫技,不炒作,
只是默默帮你——
把“随便问问”,变成“精准得到”。

抓紧转发给你身边有需要的人吧

在线体验:prompt.always200.com
GitHub:github.com/linshenkx/p…


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

深度拆解 fetch-event-source库实现原理

前言

在 AI 大模型火热的今天,流式输出(Streaming)已成为标配。虽然浏览器原生提供了 EventSource (SSE),但在复杂的业务实战中,它却显得力不从心。本文将带你深度剖析 fetch-event-source 的底层实现,看看它是如何突破原生限制,优雅实现流式交互的。

一、 为什么原生 EventSource 走到了尽头?

原生 EventSource 在 AI 聊天场景中有两个“死穴”:

  1. 方法受限:只能发送 GET 请求。AI 聊天往往需要携带庞大的上下文(Context),URL 长度限制是无法逾越的障碍。
  2. 鉴权困境:无法自定义 Header。在需要通过 Authorization 传递 Token 的现代 Web 应用中,这非常致命。

fetch-event-source 的出现,本质上是给 fetch 套上了一层 SSE 的协议外壳,完美继承了 fetch 的灵活性。


二、 核心原理:基于 ReadableStream 的流式解析

fetch-event-source 的核心魔法在于利用了 fetch 返回值中的 Response.body。它是一个 ReadableStream(可读流),允许我们在数据还没全部到达时,就开始处理已经“流”进来的字节块。

1. 协议头强制对齐

要模拟 SSE,请求头必须严格遵守规范:

  • Accept: text/event-stream:告知后端我们需要流式响应。
  • Cache-Control: no-cache:禁用缓存,确保实时性。
  • Connection: keep-alive:保持长连接。

2. 状态机解析逻辑

由于 SSE 格式具有高度可预测性(以 \n 分隔行,以 \n\n 分隔消息块),我们可以通过一个简单的状态机进行逐行扫描:

  • data: 开头 -> 暂存数据片段。
  • event: 开头 -> 记录事件类型。
  • retry: 开头 -> 更新客户端的重连等待时间。
  • 空行 (\n\n) -> 表示一条消息解析完成,触发 onmessage 回调。

三、 手写一个简易版

理解原理最好的方式就是复刻它。以下是基于 fetchTextDecoder 的核心实现逻辑:

async function fetchEventSource(url, options) {
  const { signal, onopen, onmessage, onerror, retryDelay = 1000 } = options;
  let retryCount = 0;

  // 1. 循环处理(失败重试)
  while (!signal.aborted) {
    try {
      const response = await fetch(url, {
        method: 'POST', // 突破 GET 限制,支持 POST 发送上下文
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Content-Type': 'application/json',
          ...options.headers,
        },
        body: JSON.stringify(options.body),
        signal,
      });

      // 2. 响应合法性校验
      if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
      if (!response.headers.get('Content-Type')?.includes('text/event-stream')) {
        throw new Error('Invalid Content-Type, expected text/event-stream');
      }

      onopen?.({ response });

      // 3. 读取流式响应体 (核心)
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');
      let buffer = ''; 

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        // 解码二进制数据并追加到缓冲区
        buffer += decoder.decode(value, { stream: true });
        
        // 4. 按 SSE 规范拆分消息块 (\n\n)
        let parts = buffer.split('\n\n');
        buffer = parts.pop(); // 最后一个可能是残缺的,留到下一轮处理

        for (const part of parts) {
          // 这里解析 data: event: 等字段
          const parsed = parseSSEPart(part); 
          onmessage?.(parsed);
        }
      }

      await reader.releaseLock();
      if (signal.aborted) break;

      throw new Error('Connection closed by server');
    } catch (error) {
      // 5. 错误处理与指数退避重连
      const retry = onerror?.(error) ?? true;
      if (!retry || signal.aborted) break;

      const delay = retryDelay * Math.pow(2, retryCount);
      await new Promise(resolve => setTimeout(resolve, delay));
      retryCount++;
    }
  }
}

四、 总结

fetch-event-source 并不是魔法,它只是站在了 fetchReadableStream 的肩膀上,通过手动实现 SSE 协议解析,解决了原生 API 的痛点。在 AI 对话应用中,它是实现实时、鉴权、高扩展性流式输出的最佳实践。

SSE 流式传输:中断超时处理

前言

在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接“假死”时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。

一、 为什么原生 EventSource 在 AI 场景“退环境”了?

原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个“致命伤”:

  1. 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
  2. 无法自定义 Header:无法在请求头中携带 Authorization 令牌,给鉴权带来了麻烦。

fetch-event-source 的原理:它是基于原生 fetchReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。


二、 核心实战:如何处理 SSE 异常中断与超时?

在长连接中,最怕“连接还在,但数据没了”的假死状态。我们需要对库进行二次封装,引入超时检测指数退避重连

1. 超时检测机制

设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。

  • 动作:主动调用 abort() 中断当前请求,并触发重连。
  • 重置:每当有新数据到达或连接开启时,重置该定时器。

2. 指数退避自动重连

为了减轻服务器压力,重连间隔不应是固定的。

  • 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
  • 终止:设置最大重连次数(如 10 次),失败后提示用户“服务器繁忙,请手动重试”。

3. 断点续传与去重

重连后,后端可能会重新推送历史数据。

  • 前端方案:维护一个 lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据 id 对收到的消息进行 Map 去重。

三、 中断超时处理实现:基于fetchEventSource 简易实现

import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'

// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)

/**
 * 重置超时定时器(收到消息/建立连接时调用)
 * @param {number} timeout 超时时间(默认30s)
 */
const resetTimeoutTimer = (timeout = 30000) => {
  // 清除原有定时器
  if (timeoutTimer) clearTimeout(timeoutTimer)
  // 新建超时定时器:超时未收到消息则主动中断
  timeoutTimer = setTimeout(() => {
    ElMessage.warning('连接超时,正在尝试重连...')
    controller.abort() // 主动中断请求
    reconnectStream() // 触发重连
  }, timeout)
}

/**
 * 重连流式请求(指数退避策略)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调
 */
const reconnectStream = async (url, headers, data, handleMessage) => {
  // 超过最大重连次数,停止自动重连
  if (reconnectCount >= MAX_RECONNECT_COUNT) {
    ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
      confirmButtonText: '确定'
    })
    // 重置重连状态
    reconnectCount = 0
    reconnectInterval = 2000
    return
  }

  // 指数退避:间隔翻倍,不超过30s
  const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
  ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)

  // 延迟重连
  await new Promise((resolve) => setTimeout(resolve, currentInterval))

  // 更新重连状态
  reconnectCount++
  reconnectInterval *= 2

  // 重新发起请求(携带最后一条消息ID,实现断点续传)
  requestStream(
    url,
    headers,
    {
      ...data,
      lastMessageId: lastMessageId // 传给后端,让后端从断点续传
    },
    handleMessage
  )
}

/**
 * 流式请求核心方法(带超时、重连、断点续传)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调(接收流式数据)
 */
export const requestStream = (url, headers, data, handleMessage) => {
  // 中断原有请求
  if (controller) controller.abort()
  controller = new AbortController()

  // 初始化超时定时器(30s超时检测)
  resetTimeoutTimer()

  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: {
      ...headers,
      Accept: 'text/event-stream', // SSE必需头
      'Cache-Control': 'no-cache'
    },
    body: JSON.stringify(data),
    openWhenHidden: true, // 页面隐藏时继续请求
    async onopen(response) {
      console.log('建立连接的回调')
      // 连接建立:重置超时定时器+重连状态
      resetTimeoutTimer()
      reconnectCount = 0
      reconnectInterval = 2000

      // 校验响应合法性
      if (!response.ok) {
        throw new Error(`连接失败,状态码:${response.status}`)
      }
    },
    onmessage(msg) {
      // 收到消息:重置超时定时器
      resetTimeoutTimer()

      // 记录最后一条消息ID(断点续传核心)
      if (msg.id) lastMessageId = msg.id
      // 处理消息(去重逻辑:避免重连后数据重复)
      handleMessage(msg)
    },
    onclose() {
      console.log('连接正常关闭')
      // 清除定时器+中断请求
      if (timeoutTimer) clearTimeout(timeoutTimer)
      controller.abort()
      // 重置状态
      reconnectCount = 0
      reconnectInterval = 2000
      lastMessageId = ''
    },
    onerror(err) {
      // 清除超时定时器
      if (timeoutTimer) clearTimeout(timeoutTimer)

      // 手动中断不触发重连(比如用户点击停止)
      if (controller.signal.aborted) {
        console.log('用户手动中断请求')
        return
      }

      // 异常重连
      ElMessage.error(`连接异常:${err.message || '网络错误'}`)
      reconnectStream(url, headers, data, handleMessage)

      // 必须抛出错误才会停止当前请求循环
      throw err
    }
  })
}

/**
 * 停止流式请求(手动中断)
 */
export const stopRequest = () => {
  // 清除超时定时器
  if (timeoutTimer) {
    clearTimeout(timeoutTimer)
    timeoutTimer = null
  }
  // 中断请求
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
  // 重置重连状态
  reconnectCount = 0
  reconnectInterval = 2000
  lastMessageId = ''
  ElMessage.info('已停止数据请求')
}


四、 注意:关于 Nginx 与浏览器限制

  1. Nginx 缓存屏蔽:一定要记得设置 proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。
  2. 浏览器连接数限制:如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
  3. 手动停止 vs 自动重连:当用户点击“停止生成”时,必须标记一个 manualStop 状态位,否则 onerror 可能会误以为是网络异常而不断尝试重连。

五、💡 扩展:异步并发池 (Async Pool)

它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。

1. 归属识别:唯一 ID + 专属缓存

  • 每个请求分配requestId(如stream-request-0);
  • streamDataCacherequestId为 key,每个请求的片段只往自己的缓存里加;
  • 即使多个请求的onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。

2. 有序拼接:数组按顺序存储片段

  • 每个请求的缓存里用fragments数组存储片段;
  • onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;
  • 收到结束标识[DONE]时,用join('')拼接数组,得到完整结果。

3. 并发控制:不等待 Promise 完成,只控制启动数

  • runningRequestCount记录正在运行的请求数;
  • runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;
  • 每个请求结束后(onclose/onerror),runningRequestCount--,并自动执行下一个任务;
  • 这种方式既限制了并发数,又不阻塞流式请求的 “持续返回片段”。
/**
 * 异步任务池(适配流式请求的并发控制)
 * @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
 * @param {number} limit 最大并发数
 * @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
 */
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
  // 为每个请求分配唯一ID
  const requestListWithId = requestList.map((item, index) => ({
    ...item,
    requestId: `stream-request-${index}`
  }))

  // 任务执行队列:递归执行,控制并发数
  const runTasks = async (taskIndex = 0) => {
    // 所有任务处理完毕
    if (taskIndex >= requestListWithId.length) return

    const currentTask = requestListWithId[taskIndex]
    const { requestId, url, headers, data } = currentTask

    // 等待:直到并发数低于限制
    while (runningRequestCount >= limit) {
      await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
    }

    // 启动当前流式请求
    runningRequestCount++
    console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)

    // 执行单个流式请求(不等待完成,只标记启动)
    singleStreamRequest(requestId, url, headers, data, onComplete)
      .catch((err) => console.error(`请求${requestId}失败:`, err))
      .finally(() => {
        // 当前请求结束后,自动执行下一个任务
        runTasks(taskIndex + 1)
      })

    // 立即执行下一个任务(检查并发数)
    runTasks(taskIndex + 1)
  }

  // 启动任务队列
  await runTasks(0)
}

/**
 * 停止单个/所有流式请求
 * @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
 */
export const stopStreamRequest = (requestId) => {
  if (requestId) {
    // 停止指定请求
    const controller = requestControllers[requestId]
    if (controller) {
      controller.abort()
      delete requestControllers[requestId]
      // 标记缓存为完成
      if (streamDataCache[requestId]) {
        streamDataCache[requestId].isCompleted = true
      }
      runningRequestCount--
    }
  } else {
    // 停止所有请求
    Object.keys(requestControllers).forEach((id) => {
      requestControllers[id].abort()
      delete requestControllers[id]
      if (streamDataCache[id]) {
        streamDataCache[id].isCompleted = true
      }
    })
    runningRequestCount = 0
    ElMessage.info('已停止所有流式请求')
  }
}

// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]

// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
  // 单个请求完成后的回调:拿到拼接好的完整结果
  console.log(`请求${requestId}完成,完整结果:`, fullResult)
  // 这里可以做后续处理:渲染、入库等
})

从爆红到被嫌弃,MCP 为什么开始失宠了

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

MCP 出生时,被捧得很高。

2024 年 11 月,Anthropic 发布"模型上下文协议",几乎所有 AI 开发者社区都在讨论这件事。它的定位很诱人,要成为大模型和外部工具之间通信的"通用标准",有点像当年 HTTP 对 Web 的意义。一时间,MCP server 满天飞,各种集成教程、开源实现层出不穷。

但时间只过了一年多。

上周,Perplexity 的联合创始人兼 CTO Denis Yarats 在内部表示,他们正在放弃 MCP,转而改用 APICLI。这个消息扩散出来后,引发了一波讨论,但讨论的内容不是"为什么",而是"早该如此"。

Y Combinator 的总裁兼 CEO Garry Tan 甚至直接说了一句话:"MCP sucks。"

MCP 的问题从来都不是技术实现不够好

很多人对 MCP 的质疑,停留在"不稳定"、"认证烦"这些体感上的抱怨。这些问题确实存在,但它们只是表象。MCP 真正的困境,是一个结构性问题。

MCP 的工作方式是,把工具的名称、描述、参数结构(Schema)以及使用示例,全部注入到 Agent 的上下文窗口里。Agent 读完这些信息,再决定要调用哪个工具。

这个设计在工具数量少时还可以接受。但你一旦接入 10 个服务,每个服务有 5 个工具,光是工具定义本身就已经烧掉了几千个 token。Agent 还没开始干活,上下文就已经塞满了一半。

上下文窗口是 Agent 最宝贵的资源,它决定了 Agent 能看见多少对话历史,能保留多少工作记忆,能有多大的推理空间。MCP 的代价,是把这个资源拿来"列菜单"。

面对这个问题,现有的出路只有三条:

  • 一次性加载所有工具,接受推理性能下降
  • 限制接入工具数量,接受 Agent 能力边界收窄
  • 构建动态工具加载机制,接受额外的延迟和复杂度

三条路都不好走。这不是"实现质量"的问题,而是协议设计本身的代价。

除此之外,日常使用中的痛点也不少。MCP server 启动失败是家常便饭,有时重试能解决,有时必须推倒重来。接入多个服务就要在每个服务上重新认证一遍。权限管理也只有"允许"和"不允许"两档,没有办法把某个工具限制为只读,也没有办法约束它可以传什么参数。

CLI 是更好的答案,不是因为它新,而是因为它够旧

工程师 Eric Holmes 写过一篇文章,观点直接:MCP 没有带来任何实际价值,LLM 完全可以自己搞懂怎么用 CLI

这话有点刺,但它说的是实情。

大模型在训练时看过海量的 man 手册、Stack Overflow 回答和 GitHub 上的 Shell 脚本。它们对 CLI 的理解,远比对某个 MCP server 的理解深得多。给它一个命令行工具和一份文档,它就能上手,不需要特殊适配。

CLI 在几个关键点上,比 MCP 天然占优。

第一是可调试性。当 Claude 对 Jira 执行了一个出乎意料的操作,你可以直接跑同一条 jira issue view 命令,看看它看到了什么。输入一致,输出一致,没有谜团。但 MCP 的调用只发生在 LLM 的对话内部,出问题了只能去翻复杂的 JSON 传输日志。

第二是可组合性。这是 CLI 的核心竞争力。你可以用 jq 过滤数据,用 grep 串联逻辑,把输出重定向到文件。这不只是方便,很多时候这是唯一可行的路。MCP 没有这个能力,你要么把完整数据塞进上下文,要么在 server 端自己写过滤逻辑,两种方式都在用更多的精力换取更差的结果。

第三是认证。CLI 复用的是系统级别的认证体系,这套东西已经经过几十年的打磨。MCP 需要你重新为每个工具搭一遍认证流程。

这件事说明了什么

Perplexity 放弃 MCP,以及其他工具陆续移除 MCP 支持,这件事背后有一个更值得思考的信号。

给 AI 构建工具链,不需要发明一套新的协议。AI 需要的工具,和人类需要的工具,在很多时候是同一套。最好的工具是对人类和机器都好用的工具。

CLI 存在了几十年,设计上一直遵循一个哲学,每个工具做好一件事,然后把工具组合起来解决复杂问题。这套哲学放到 Agent 身上,依然成立。

MCP 想构建一个更"现代"的抽象层,但它解决的问题,现有工具已经解决得够好了。在不需要额外抽象的地方强行加一层,带来的只有额外的成本和复杂度。

当然,MCP 不会完全消失。在某些特定场景,比如需要强类型 Schema、有严格访问控制要求的企业内部系统,它依然有它的位置。但作为"AI 工具集成的通用标准",这个定位恐怕很难站稳了。

参考:

告别登录中断:前端双 Token无感刷新

前言

在前后端分离的项目中,为了安全,Token 通常会设置有效期。但如果 Token 过期时强制用户重新登录,会极大地破坏用户体验。如何做到在用户毫无察觉的情况下,自动完成 Token 的续期?本文将深度拆解 “双 Token 无感刷新” 的实现机制。

一、 为什么需要“无感刷新”?

举个简单例子,你正在某 App 编辑内容,中途切出几分钟,再切回来时,直接弹出登录页,提示“登录已过期,请重新登录”,这种场景很容易让用户流失。

传统的单 Token 方案存在一个两难境地:

  • 有效期过短:用户操作频繁,动不动就跳回登录页,用户体验极差。
  • 有效期过长:Token 一旦被截获,风险极高。

解决方案:双 Token 机制

  1. access_token:访问令牌。有效期短(如 1 小时),每次接口请求都携带,降低泄露风险。
  2. refresh_token:刷新令牌。有效期长(如 7 天),仅用于 access_token 过期时换取新令牌。

只要用户在 7 天内活跃过,系统就能通过 refresh_token 自动“续命”,实现长效无感登录。


二、 核心流程设计

  1. 正常请求:前端携带 access_token 访问。

  2. 触发过期:后端返回 401 Unauthorized

  3. 判断逻辑

    • 如果是普通接口报 401:说明 access_token 失效,尝试刷新。
    • 如果是刷新接口报 401:说明 refresh_token 也失效了,强制重新登录。
  4. 无感替换:前端自动调用刷新接口,获取新 Token 覆盖本地存储,并重新发起之前失败的请求。


三、 细节攻坚:如何处理并发请求?

痛点:如果页面同时发出了 5 个请求,而此时 Token 刚好过期,会导致这 5 个请求同时触发“刷新 Token”的操作,造成资源浪费甚至后端异常。

解决策略

  • 状态锁 (refreshing) :记录当前是否正在刷新中。
  • 任务队列 (queue) :在刷新期间到达的请求,先暂存起来,不直接报错。
  • 批量回放:等待 Token 刷新成功后,依次执行队列里的请求,实现“无感”衔接。

四、 代码实现 (Axios 拦截器)

以下是基于 Axios 的完整工程化实现:

import axios, { AxiosRequestConfig } from 'axios';

interface PendingTask {
    config: AxiosRequestConfig;
    resolve: Function;
}

let refreshing = false; // 状态锁:标志是否正在刷新 Token
let queue: PendingTask[] = []; // 请求队列:暂存 Token 刷新期间的请求

const axiosInstance = axios.create({
    baseURL: '/api'
});

// 1. 请求拦截器:自动注入 Token
axiosInstance.interceptors.request.use((config) => {
    const accessToken = localStorage.getItem('access_token');
    if (accessToken && config.headers) {
        config.headers.authorization = `Bearer ${accessToken}`;
    }
    return config;
});

// 2. 响应拦截器:处理 Token 过期
axiosInstance.interceptors.response.use(
    (response) => response,
    async (error) => {
        const { data, config } = error.response;

        // 情况 A:正在刷新 Token 中,将后续请求存入队列
        if (refreshing) {
            return new Promise((resolve) => {
                queue.push({ config, resolve });
            });
        }

        // 情况 B:access_token 过期 (状态码 401 且非刷新接口本身)
        if (data.statusCode === 401 && !config.url.includes('/refresh')) {
            refreshing = true;
            
            try {
                const res = await refreshToken();
                refreshing = false;

                if (res.status === 200) {
                    // 核心逻辑:Token 刷新成功,回放队列中的所有请求
                    queue.forEach(({ config, resolve }) => {
                        resolve(axiosInstance(config));
                    });
                    queue = []; // 清空队列
                    
                    // 执行当前触发刷新的那个请求
                    return axiosInstance(config);
                }
            } catch (err) {
                refreshing = false;
                queue = [];
                // 情况 C:refresh_token 也过期了,彻底清除登录态
                localStorage.clear();
                window.location.href = '/login';
                return Promise.reject(err);
            }
        }

        return Promise.reject(error);
    }
);

/**
 * 刷新 Token 的异步方法
 */
async function refreshToken() {
    const res = await axios.get('/api/refresh', {
        params: {
            token: localStorage.getItem('refresh_token')
        }
    });
    // 更新本地存储
    localStorage.setItem('access_token', res.data.accessToken);
    localStorage.setItem('refresh_token', res.data.refreshToken);
    return res;
}

五、 注意事项

  1. 并发请求的 Promise 挂起:在 refreshingtrue 时,返回一个不带 resolvenew Promise 是关键,它能让 Axios 请求处于 pending 状态。
  2. 错误捕获refreshToken 接口本身报错(如 500 或 401)必须妥善处理,直接引导至登录页。
  3. 安全性:普通项目中可以使用 localStorage,但在更高要求的项目中,建议配合 HttpOnly Cookie 存储 refresh_token 以防 XSS 攻击。
  4. 接口重定向陷阱:确保刷新 Token 的接口不会再次进入 401 拦截死循环。

SSO单点登录:从同域到跨域实战

前言

在企业级应用集群中,如果用户每打开一个内部系统都要重新输入一次密码,体验将是灾难性的。SSO(Single Sign-On) 的出现解决了这一痛点:它允许用户“一处登录,处处通行”。本文将深度拆解 SSO 的两种核心实现逻辑。

SSO 的核心概念

SSO(单点登录) 是指在多个应用系统中,用户只需要登录一次,就可以访问所有相互信任的应用系统。

典型场景: 登录了“支付宝”网页版后,直接打开“淘宝”、“天猫”或“阿里云”,你会发现自己已经处于登录状态。


二、 方案一:同域名下的 SSO(父域 Cookie 共享)

这是最简单的实现方式,利用了浏览器 Cookie 可以跨子域共享 的特性。

1. 实现原理

如果所有系统的域名都属于同一个顶级域名(如 a.company.comb.company.com),我们可以将 Cookie 的 Domain 设置为父级域名 .company.com

2. 执行流程

  1. 重定向: 用户访问业务系统 A,A 发现未登录,跳转至 sso.company.com
  2. 认证: 用户在 SSO 页面完成登录。
  3. 种下全局 Cookie: SSO 验证成功,在响应头设置 Set-Cookie: sessionid=xxx; Domain=.company.com; Path=/,这样所有子域名系统都会自动带上这个 Cookie
  4. 自动带入: 当用户跳转回系统 A 或访问系统 B 时,浏览器会自动带上这个 .company.com 域下的 Cookie。
  5. 校验: 业务系统后端获取 Cookie 并请求 SSO 服务验证有效性,完成登录。

注意: 该方案仅适用于公司内部子系统,局限性在于必须处于同一父域下。


三、 方案二:跨域名下的 SSO(Token+code模式)

当系统域名完全不同(如 taobao.comalipay.com)时,Cookie 无法跨域共享。此时需要一个独立的 统一认证中心(CAS/SSO)

核心流程:Token + Code 交换模式

1. 首次登录(以系统 A 为例)

  1. 路由拦截: 业务系统 A 的路由守卫发现本地无 token
  2. 跳转认证: A 引导用户跳转至 SSO 登录页,并携带回跳地址:https://sso.com/login?client_id=A&redirect_uri=https://a.com/callback
  3. SSO 认证: 用户在 SSO 完成登录,SSO 在自己的域名(sso.com)下种下 全局登录态 Cookie
  4. 下发 Code: SSO 生成一个临时授权码 code,通过 URL 重定向带回给系统 A:https://a.com/callback?code=xxxxxx
  5. 换取 Token: 系统 A 前端获取 code,再次向 SSO 服务发起请求。SSO 校验 Cookie + code 有效后,返回正式的 token
  6. 本地存储: 系统 A 获取 token 后存储在 localStorage 或本地 Cookie 中,登录成功。

2. 二次登录(访问系统 B)

  1. 无感跳转: 用户打开系统 B,B 发现未登录,跳转至 SSO 系统。
  2. Cookie 自动识别: 此时浏览器会自动带上 sso.com 域下的全局 Cookie。
  3. 直接授权: SSO 发现用户已登录,直接生成一个新的 临时 code 并重定向回系统 B。
  4. B 换取 Token: 系统 B 使用新 code 换取属于 B 的 token,实现单点登录。

四、 注意事项

1. 为什么不直接返回 Token,而是用 Code 换取?

安全性。 如果直接在 URL 中返回 Token,Token 会暴露在浏览器历史记录中,容易被窃取。使用 临时 code(通常有效期仅 1-5 分钟且只能使用一次)配合后端校验,安全性更高。

2. 重定向时的“瞬间空白”如何处理?

跨域 SSO 在进行域名跳转时,由于需要经过 SSO 系统的中转判断,不可避免会有短暂的白屏或闪烁。

  • UI 优化: 在重定向过程中展示一个统一的 Loading 动画。
  • 静默校验: 如果技术条件允许,可以通过 iframe 尝试静默检查 SSO 登录态,减少全屏跳转。

3. 安全增强

  • State 参数: 在跳转时增加一个随机字符串 state,并在回调时比对,防止 CSRF(跨站请求伪造) 攻击。
  • HTTPS: SSO 全流程必须在 HTTPS 协议下进行,防止敏感信息被中间人劫持。

五、 总结

跨域 SSO 的核心思想是:将“身份验证”权力收拢到统一认证中心,利用 SSO 域下的 Cookie 维持全局登录态,通过“授权码交换”实现跨域权限传递。

React Scheduler & Lane 详解

一、核心前置概念

在讲解Scheduler和Lane之前,先明确3个面试常考的基础概念,帮你快速理解二者的作用场景:

1. Lane:优先级的抽象(通俗版+专业版)

通俗解释:把React中的每一种更新优先级,想象成一条“车道”——高优先级更新走“快车道”,能插队;低优先级更新走“慢车道”,会被快车道的车辆(高优先级更新)打断,这样就能精准区分不同更新的执行顺序,避免混乱。

专业定义:Lane是React用于精细化管理更新优先级的核心机制,替代了早期的过期时间模型,本质是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算实现更新的合并、筛选与优先级判断,是React并发更新的基石。

2. 同步更新 vs 并发更新(面试高频区分)

同步更新:更新任务一旦开始,就会从头到尾执行完毕,期间会阻塞主线程(比如长时间渲染列表时,用户点击按钮没反应),React18之前默认是同步更新。

并发更新:React18引入的核心特性,允许高优先级更新打断低优先级更新,任务可以“暂停、恢复、中断”,不会阻塞主线程,能同时处理多个不同优先级的更新(比如一边渲染列表,一边响应用户输入),而Scheduler和Lane正是支撑并发更新的核心。

3. 时间切片(Time Slicing,Scheduler的核心能力)

通俗解释:浏览器每16.6ms会刷新一帧(保证页面不卡顿),时间切片就是把长任务“切”成一个个≤16.6ms的小任务,每执行一个小任务,就检查一下浏览器是否需要渲染,若需要就暂停任务、让出主线程,避免卡顿。

专业定义:React Scheduler实现的一种任务调度机制,默认切片时长约16.6ms(对应60fps),通过shouldYieldToHost方法判断是否中断当前任务,确保任务执行不阻塞浏览器的渲染和用户交互。

二、Scheduler(调度器)详解(面试核心)

1. 核心定义(必背)

React Scheduler 是 React 并发模式的核心模块,本质是“任务管理者”,负责协调 React 中所有更新任务的优先级、执行时机,调控任务队列,避免重型任务阻塞主线程(如用户交互、渲染),确保应用流畅响应,核心目标是“推进任务,且不阻塞浏览器”。

2. 核心功能(详细且简洁,面试重点背诵)

记住:Scheduler的核心就是“管优先级、管队列、管执行”,4个核心功能如下,结合通俗解释更好记:

  • 优先级调度(核心中的核心):定义5种核心优先级(从高到低),精准匹配不同场景,优先执行高优先级任务,同时避免低优先级任务“饥饿”(长期不执行)。

    通俗补充:就像医院挂号,急诊(高优先级)优先看病,普通门诊(低优先级)排队,但不会让普通门诊病人一直等不到(过期机制)。

    专业细节:5种优先级对应场景+超时时间(面试可简要提及):

    • ImmediatePriority(立即执行):对应SyncLane,超时时间0ms(比如用户点击按钮的同步更新);
    • UserBlockingPriority(用户阻塞):对应输入/滚动,超时时间250ms(比如打字、拖拽,必须快速响应);
    • NormalPriority(普通优先级):普通setState更新默认,超时时间5000ms;
    • LowPriority(低优先级):对应过渡更新,超时时间10000ms(比如useTransition包裹的非紧急更新);
    • IdlePriority(空闲优先级):空闲时执行,超时时间无限(比如后台日志、预加载)。
  • 任务队列管理:维护两个最小堆结构的队列,高效管理任务(堆结构能快速找到最高优先级任务):

    • taskQueue(立即可执行任务):按任务过期时间排序,堆顶是最先过期、优先级最高的任务;
    • timerQueue(延迟执行任务):按任务开始时间排序,比如延迟执行的后台任务,到期后移至taskQueue。
  • 时间切片与中断:实现时间切片(默认≤16.6ms),通过shouldYieldToHost方法判断是否中断任务——若任务执行超时,或浏览器需要渲染、处理用户交互,立即让出主线程,待浏览器空闲后再恢复执行,避免卡顿。 通俗补充:就像写作业,每写20分钟(对应16.6ms),就检查一下有没有人叫你(浏览器是否需要工作),有就暂停,没人叫就继续写。

  • 任务调度循环:通过workLoop(工作循环)推进任务,核心流程:① 先将timerQueue中到期的任务移至taskQueue;② 从taskQueue中取出堆顶的最高优先级任务执行;③ 若任务未执行完毕(返回回调函数),则放回taskQueue,等待下一轮调度;④ 重复以上步骤,直到队列清空。

三、Lane(车道模型)详解(面试核心)

1. 核心定义(必背)

Lane 是 React 用于精细化管理更新优先级的机制,替代了早期的过期时间模型,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级,通过高效位运算(按位与、按位或)实现更新的合并、筛选与优先级判断,适配并发模式下的中断与恢复需求,是 React 并发更新的基石。

通俗补充:31位整数就像31条并行的车道,每条车道跑一种优先级的更新,位运算就是“快速判断哪条车道有车、能不能合并车道、哪条车道的车优先级最高”,比传统的队列遍历高效得多(O(1)复杂度)。

2. 核心功能(详细且简洁,面试重点背诵)

  • 优先级精细化划分:将更新划分为5类核心车道,对应Scheduler的5种优先级,分工明确(面试常考对应关系):

    • SyncLane(最高优先级):对应Scheduler的ImmediatePriority,比如用户点击、输入等同步更新,不能被中断;
    • InputContinuousLane(很高优先级):对应UserBlockingPriority,比如滚动、拖拽等连续交互,需快速响应;
    • DefaultLane(中等优先级):对应NormalPriority,普通setState更新默认走这条车道;
    • TransitionLanes(可中断优先级):对应LowPriority,有16条车道(可并行处理多个过渡更新),比如useTransition、useDeferredValue包裹的更新,可被高优先级更新打断;
    • IdleLane(最低优先级):对应IdlePriority,比如后台预加载、日志上报,只有浏览器空闲时才执行。
  • 高效位运算管理:通过位运算实现O(1)复杂度的更新操作(面试常考位运算场景):

    • 按位或(|):合并多条车道的更新,比如同时有普通更新和过渡更新,就用位或把两条车道合并,统一处理;
    • 按位与(&):检查特定车道是否有未处理的更新,比如判断是否有同步更新,就用SyncLane和pendingLanes(待处理车道)做按位与运算,结果非0则有同步更新。 通俗补充:位运算就像“快速检票”,不用一个个查,一句话就能判断所有车道的状态,效率极高。
  • 并发中断与恢复:低优先级车道(如TransitionLanes)的更新执行过程中,若有高优先级车道(如SyncLane)的更新插入,可立即中断低优先级任务,优先处理高优先级任务;待高优先级任务完成后,再恢复或重新执行低优先级任务,保障用户交互流畅性。 通俗补充:就像慢车道的车正在行驶,快车道的车来了,慢车道的车就让道,等快车道的车过去,再继续走。

  • 过期与优先级提升:为避免低优先级任务长期被高优先级任务阻塞(饥饿问题),React会为每条车道计时,若某条车道的更新待处理过久(超过对应超时时间),会将其优先级提升至SyncLane(立即执行),确保UI最终一致性,不会出现“更新一直不生效”的情况。

四、Scheduler 与 Lane 的关联(面试高频,必背)

核心总结:Lane 负责“定义更新优先级”(告诉React“这个更新该用什么优先级”),Scheduler 负责“执行优先级调度”(告诉React“这个优先级的任务什么时候执行、怎么执行”),二者协同工作,支撑React并发模式,核心关联3点:

  1. 优先级映射:Lane的每类车道,都对应Scheduler的一种优先级(一一对应,面试必背):
  • SyncLane ↔ ImmediatePriority(立即执行)
  • InputContinuousLane ↔ UserBlockingPriority(用户阻塞)
  • DefaultLane ↔ NormalPriority(普通优先级)
  • TransitionLanes ↔ LowPriority(低优先级)
  • IdleLane ↔ IdlePriority(空闲优先级)
  1. 协同工作流程(面试可完整背诵,体现逻辑):
  • 触发更新(如setState、useState更新);
  • React为该更新分配对应Lane,并将Lane加入根节点的pendingLanes(待处理车道);
  • Scheduler读取pendingLanes,将其映射为自身的优先级;
  • Scheduler从taskQueue中挑选最高优先级任务执行;
  • 执行过程中,通过Lane检查是否有更高优先级更新插入,若有则中断当前任务;
  • 高优先级任务执行完毕后,恢复或重新执行低优先级任务,形成调度闭环。
  1. 核心目标一致:二者的最终目的都是解决“主线程阻塞”问题——Lane实现优先级的精细化区分,让不同更新有明确的执行顺序;Scheduler实现任务的高效调度与中断,让任务执行不影响浏览器渲染和用户交互,共同保障React应用在复杂场景下(如大量数据渲染、高频交互)的流畅性。

五、面试常考问题(标准答案,可直接背诵)

说明:以下问题均为React面试高频题,答案简洁精准,贴合面试场景,避开复杂细节,重点突出核心考点。

1. 请说说React Scheduler的作用?

标准答案:Scheduler是React并发模式的核心模块,本质是“任务管理者”,核心作用是协调所有更新任务的优先级和执行时机,通过维护任务队列、实现时间切片和任务中断,避免重型任务阻塞主线程,确保应用的流畅响应(核心目标:推进任务,且不阻塞浏览器)。

2. Lane是什么?它的核心作用是什么?

标准答案:Lane是React用于精细化管理更新优先级的机制,核心是用31位整数的每一位(位掩码)代表一条“车道”,每一条车道对应一种更新优先级。核心作用是通过高效位运算,实现更新的合并、筛选与优先级判断,支撑React并发模式下的任务中断与恢复,解决优先级区分不精细的问题。

3. Scheduler和Lane的关系是什么?

标准答案:二者协同支撑React并发特性,核心关系是“Lane定义优先级,Scheduler执行调度”。Lane负责给更新分配优先级(对应不同车道),Scheduler负责将车道优先级映射为自身优先级,管理任务队列、执行任务,并通过Lane判断是否需要中断任务,共同解决主线程阻塞问题,保障应用流畅。

4. 什么是时间切片?它由哪个模块实现?作用是什么?

标准答案:时间切片是将长任务切分为≤16.6ms的小任务,每执行一个小任务就检查浏览器是否需要渲染,避免阻塞主线程的机制。由Scheduler实现,核心作用是防止重型任务(如大量列表渲染)阻塞主线程,保障用户交互和页面渲染的流畅性。

5. React中高优先级更新为什么能打断低优先级更新?依赖什么机制?

标准答案:依赖Lane和Scheduler的协同机制。Lane将更新划分为不同优先级的车道,高优先级更新对应高优先级车道;Scheduler在执行任务时,会通过Lane检查是否有更高优先级更新插入,若有则立即中断当前低优先级任务,优先执行高优先级任务,执行完毕后再恢复低优先级任务,从而实现高优先级打断低优先级。

6. 如何避免低优先级更新“饥饿”(长期不执行)?

标准答案:React通过Lane的“过期机制”避免低优先级更新饥饿。为每条车道设置对应超时时间,若某条低优先级车道的更新待处理过久(超过超时时间),会将其优先级提升至SyncLane(最高优先级),由Scheduler立即执行,确保UI最终一致性。

7. Lane为什么用位掩码实现?优势是什么?

标准答案:因为位运算的时间复杂度是O(1),效率极高。优势是能快速实现更新的合并(按位或)、筛选(按位与)和优先级判断,无需遍历任务队列,适配React高频更新场景(如滚动、输入),提升调度性能。

【JavaScript面试题-作用域与闭包】什么是闭包?闭包在实际开发中有什么应用和潜在问题(如内存泄漏)?

什么是闭包?

闭包是指一个函数能够访问并记住其词法作用域(即定义时的作用域)中的变量,即使这个函数是在其词法作用域之外被执行的。简单来说,闭包让你可以在内层函数中访问到外层函数的作用域

在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。但是,闭包通常特指那些引用了外部函数作用域中变量的内部函数。

一个形象的比喻

你出生在你家的老房子里,房子里有你的玩具、书桌、窗外的树(这些都是环境变量)。后来你长大了,搬到了新城市,但你心里永远记得老房子的样子——甚至你还能描述出玩具放在哪个抽屉里(这就是闭包)。

这里的“你”就是内部函数,老房子就是外层函数的作用域。虽然你离开了老房子(外层函数执行结束),但你依然能回忆起里面的细节(访问外层函数的变量)。

简单来说,闭包就是一个能“记住”并继续使用它出生时周围环境(变量)的函数,即使这个函数后来被拿到别的地方去执行,它也忘不掉那些“老家”的变量。

一个典型的闭包示例:

javascript

function outerFunction(x) {
  let y = 10;
  function innerFunction() {
    console.log(x + y); // innerFunction 访问了 outerFunction 的变量 x 和 y
  }
  return innerFunction;
}

const closureFunc = outerFunction(5);
closureFunc(); // 输出 15,即使 outerFunction 已经执行完毕,innerFunction 依然能记住 x 和 y

在上面的例子中,innerFunction 就是一个闭包。它“捕获”了 x 和 y,使得这些变量在 outerFunction 执行完毕后仍然存在于内存中,供 innerFunction 后续调用。

闭包的实际应用

闭包在前端开发中应用非常广泛,以下是一些常见场景:

  1. 数据私有化与封装
    通过闭包可以模拟私有变量,隐藏实现细节,只暴露有限的接口。

    javascript

    function createCounter() {
      let count = 0;
      return {
        increment: function() { count++; },
        decrement: function() { count--; },
        getCount: function() { return count; }
      };
    }
    const counter = createCounter();
    counter.increment();
    console.log(counter.getCount()); // 1,无法直接访问 count 变量
    
  2. 函数柯里化(Currying)
    利用闭包将多参数函数转换为一系列单参数函数,提高函数复用性。

    javascript

    function multiply(a) {
      return function(b) {
        return a * b;
      };
    }
    const double = multiply(2);
    console.log(double(5)); // 10
    
  3. 回调函数与事件处理
    在异步操作或事件监听中,闭包可以记住当时的环境变量。

    javascript

    for (var i = 0; i < 3; i++) {
      setTimeout(function() {
        console.log(i); // 如果不使用闭包,会输出 3,3,3
      }, 100);
    }
    // 利用闭包修复:
    for (var i = 0; i < 3; i++) {
      (function(j) {
        setTimeout(function() {
          console.log(j); // 输出 0,1,2
        }, 100);
      })(i);
    }
    
  4. 模块化模式(Module Pattern)
    在ES6模块之前,闭包常用于创建模块,管理私有状态和公共API。

    javascript

    var myModule = (function() {
      var privateVar = 0;
      function privateMethod() { /* ... */ }
      return {
        publicMethod: function() { /* 可以使用 privateVar 和 privateMethod */ }
      };
    })();
    
  5. 函数式编程中的高阶函数
    例如 Array.map()Array.filter() 中传入的回调函数也常常形成闭包,访问外部作用域。

闭包的潜在问题:内存泄漏

闭包虽然强大,但如果不加注意,可能会导致内存泄漏,因为闭包会一直持有对外部函数变量的引用,使得这些变量无法被垃圾回收(GC)。

常见的内存泄漏场景:

  • 无意的全局变量
    在函数内部意外创建的全局变量,由于被全局对象引用,永远不会被回收。

  • 未解除的事件监听器
    如果在DOM元素上绑定了事件回调,而回调中使用了外部变量(闭包),并且没有在元素移除前解绑,那么整个闭包作用域链都不会被释放,造成泄漏。

    javascript

    function attachEvent() {
      const largeData = new Array(1000000).fill('*');
      document.getElementById('btn').addEventListener('click', function() {
        console.log(largeData.length); // largeData 被闭包引用
      });
    }
    attachEvent(); // 即使元素被移除,由于事件监听未解绑,largeData 依然存在
    
  • 在循环中创建闭包并引用大对象
    如果循环内创建的闭包长期存在(例如存储到数组中或作为回调),且引用了外部作用域的大对象,可能导致大量内存无法释放。

  • 定时器或回调未清除
    未清除的 setInterval 或 setTimeout 回调中的闭包会持续持有外部变量。

如何避免内存泄漏

  1. 及时解除引用

    • 在不需要时,将闭包变量设置为 null,切断引用。
    • 移除DOM元素前,先移除其绑定的事件监听器(removeEventListener)。
    • 清除定时器(clearInterval / clearTimeout)。
  2. 使用弱引用(WeakMap/WeakSet)
    当需要缓存数据但不想阻止垃圾回收时,可以使用 WeakMap 或 WeakSet,它们不会增加引用计数。

  3. 避免不必要的闭包
    只在必要时创建闭包,尽量减少闭包引用的变量体积。例如,如果只需要外部函数中的一小部分数据,可以考虑只传递所需值,而不是整个作用域。

  4. 使用现代框架/工具辅助
    现代框架(如React、Vue)通常会自动处理事件监听和组件卸载时的清理工作,但在手动操作DOM时仍需小心。

总结

闭包是JavaScript语言的一大特色,它让函数变得极其灵活,支持数据私有化、函数式编程等高级用法。但同时,闭包也可能因持有外部引用而导致内存泄漏,开发者需要理解其工作原理,并在实际开发中注意及时清理不再需要的引用,从而编写出高效、可靠的应用。

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

#前端#干货

2026 年前端 Agent 框架选型:Mastra 与 LangChain 该怎么选

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

2026 年的前端圈卷出了新高度,AI Agent 已是各类 Web 应用的标配。官网智能客服、内部任务助手、产品内的搜索与推荐,都绕不开一件事:用哪个框架把大模型和工具串起来。不少团队会在 LangChain.js 和 Mastra 之间反复纠结,架构评审时也常为此争论。

两者没有绝对优劣,差别主要在"设计哲学"和"业务场景"的匹配度。Mastra 像为前端量身定制的厨师刀,刀刃顺手、切菜切肉都轻松;LangChain.js(尤其是 LangGraph.js)则像重型瑞士军刀,刀锯镊子开瓶器齐全,能应付各种复杂场景,代价是重量和复杂度都更高。下文从 2026 年技术生态出发做一次对比,并配上代码与图示,方便你理清思路、少走弯路。

为什么前端选型会卡在这两个框架上

前端接大模型、做 Agent,本质是三件事:把用户输入送给 LLM、根据输出决定下一步(是否调工具、是否多轮对话)、再把结果还给用户。不同框架在这三条链路上的抽象程度和侧重点差异很大。一类把"循环调工具、拼消息"全包在内部,对外只暴露"发消息、拿回复",你几乎不用关心内部调了几轮工具;另一类把节点、边、状态都暴露给你,自己搭图,灵活性高,但概念和代码量都上去,得先建立"图"的思维才能写得顺手。

LangChain 从 Python 生态长出来,后有 langchain-js,再后来复杂编排催生了 LangGraph,面向任意语言和部署环境的通用编排,概念多、集成广,前端只是消费端之一。文档里会反复出现 Runnables、LCEL、RunnableSequence、RunnablePassthrough 以及各种 @langchain/xxx 包,学习路径会先经过"什么是 Runnable、reducer、checkpointer"这一串概念。Mastra 则从 TypeScript 和现代前端框架出发,默认你在用 Next.js、Nuxt 等全栈框架,API 和类型系统都围着前端习惯转,包名和概念更收敛,文档集中在"在 React、Server Actions 里怎么用",很少逼你先学一整套编排术语。

选型归根结底就两点:团队和产品更接近"通用 AI 编排"还是"前端优先的轻量 Agent"。前者偏向后端或全栈做复杂系统,愿意为灵活性和生态付学习成本;后者偏向前端或小团队在现有 Web 应用里快速接一层智能,希望少概念、少依赖、快上线。

如下图所示。

20260314114857

两种哲学一目了然:一侧是前端优先的轻量体验,一侧是通用编排与生态。

Mastra 的定位与优势

Mastra 从诞生起就面向 TypeScript 和现代前端框架(Next.js、Nuxt 等),针对前端痛点,主打开发者体验(DX)。

TypeScript 原生与类型安全

端到端类型推导做得很好:Agent 的输入、输出和工具调用参数在 IDE 里都有完整类型提示和自动补全,不必手写类型转换或 as 断言。工具用 zod 或 TypeScript 类型定义入参,框架自动生成模型可用的 schema 并做运行时校验。例如在 createTool 里写 inputSchema: z.object({ location: z.string() }),调用时入参即被推断为 { location: string },返回值与 outputSchema 对齐,和现有基于 zod 的表单校验、API 契约也容易打通。

轻量且贴合全栈框架

部署在 Vercel、Cloudflare Workers 等 Serverless 或 Edge 上时,Mastra 的冷启动和边缘兼容性通常更好。没有 LangChain 那套 Runnables、LCEL 等抽象层,依赖树干净,打包体积可控,不必为"跑通一个带工具的 Agent"拉满 @langchain/core@langchain/openai@langchain/langgraph 等一长串包。在 Next.js 的 Server Action、Route Handler 里直接调 Mastra Agent,心智负担小,和现有数据流(表单、状态、API)易对齐,也方便和 React Server Components、流式 SSR 配合。

心智负担低

API 贴近前端数据流直觉:发一段消息、拿一段回复、必要时调几个工具。Mastra 把 LLM 调度、工具解析和流式输出包起来,用简单异步函数或 React 友好接口暴露,不必理解"图、节点、条件边、reducer",会写 createToolnew Agent、会调 generate 或流式方法就能跑通,适合作为团队第一个 Agent 项目的起点。

适合的场景小结

Mastra 特别适合这几类情况:

  • Agent 主要是 Web 应用的辅助功能(智能搜索、客服助手、简单数据总结或表单建议),且深度绑定 Next.js、React 生态。
  • 团队以前端或全栈为主,不想引入过重后台架构,希望快速迭代上线,同时要类型安全和良好调试体验。
  • 对依赖体积、冷启动和 Edge 兼容性敏感,不想为用不到的能力背上整座 LangChain 生态。

LangChain.js 与 LangGraph 的定位与优势

到 2026 年,单纯用 LangChain 搞复杂 Agent 已不够用,实际在评估的往往是 LangGraph.js,它是处理复杂、有状态、多 Agent 协作时的常用方案。

生态系统覆盖广

冷门向量库、大模型厂商、各种外部 API,LangChain 生态里大多已有现成集成。Pinecone、Weaviate、Qdrant、Chroma、自建 REST、OpenAI、Anthropic、Cohere、国产大模型,以及 Tavily、SerpAPI 等,多数有官方或社区的 @langchain/xxx 包。公司内有老旧系统、私有模型或特定协议时,也容易在现有集成上做薄封装,复用 LangChain 的 Runnable、消息格式和工具约定,快速对接大量外部依赖时能省下不少适配和调试时间。

状态机与图逻辑(LangGraph)

需要"循环思考、多路分支、人类介入(Human-in-the-loop)"的复杂工作流时,LangGraph 的图架构能精确控制节点流转。节点是处理单元(一次 LLM 调用、工具执行或人工审核),边是状态转移(固定边或条件边)。状态可持久化到 checkpointer,刷新或断线重连后从断点继续,适合多轮任务和多人协作,也是 Mastra 目前不直接提供的部分。

过度抽象的代价

学习曲线陡:Runnables、Chains、Tools、Nodes、Edges、Annotation、reducer、checkpointer 等概念交织,新手易迷路。实现"用户问一句、模型调一次工具再回答"这种简单功能,也要先理解状态结构、写 agent 与 tools 节点、配条件边和普通边再 compile,代码量明显多于"Agent 配置 + 一次 generate"。报错常来自链式调用的某一层,堆栈里是 LangChain 内部的 Runnable 名,前端背景的开发者需要时间习惯"从图的角度想问题"。LangGraph 的 TypeScript 类型虽完整,但状态是运行时用 Annotation 和 reducer 拼出来的,和 Mastra 那种"工具入参即 zod schema、一眼能看出类型"的体验比,心智负担更大。

适合的场景小结

LangChain、LangGraph 更适合这几类情况:

  • 核心业务就是复杂 AI 系统:多 Agent 协作、长时运行异步任务、或需精准控制"思考中断与恢复"。
  • 集成需求多且杂,要接内部老旧系统或非常小众的向量库、模型接口。
  • 要对底层 Prompt、重试、记忆(Memory)做深度定制,甚至改框架默认行为。

核心能力对比

用一张表概括两个方向在关键维度上的差异,细节在前后文展开。

维度 Mastra LangChain.js / LangGraph
设计核心 极致 DX、原生 TS、轻量化 复杂编排、状态管理、大生态
学习曲线 平缓,熟悉 TS 即可快速上手 陡峭,需理解大量框架专属概念
调试体验 堆栈清晰,贴合前端习惯 多层抽象,报错有时难以定位
多 Agent 支持,更适合简单链式交互 极强,循环与状态打断控制完善
生态与集成 精选集成,覆盖主流工具 海量集成,几乎覆盖常见基础设施
依赖与体积 包少、体积小,Edge 友好 多包组合,体积与冷启动略大

Mastra 通常只需 @mastra/core 加模型适配(如 OpenAI),LangChain 则常需 @langchain/core@langchain/openai(或其它模型包)、@langchain/langgraph,再接向量库或 RAG 还会多几个包,在 Serverless 冷启动和 Edge 里更敏感一些。

如下图所示。

20260314115005

从设计重心到依赖体积,一张图能看清两边差异。下面用两段代码对比同一需求的实现方式,再给出选型决策说明。

用代码感受两种 API 风格

同一需求"做一个能查天气的对话 Agent",在 Mastra 和 LangGraph 里写出来的代码量和抽象层次差很多,看一遍再想选型会直观不少。

Mastra:工具 + Agent 几行搞定

在 Mastra 里用 createTool 定义工具的入参(zod)、描述和执行函数,创建 Agent 时把工具挂上去即可。调用时直接 agent.generate() 或流式接口,不用关心"模型要不要调工具、调完要不要再推理",框架内部处理。

下面示例定义了一个查天气工具和一个使用该工具的 Agent。工具入参用 z.object 声明,execute 的返回值与 outputSchema 一致,整条链路在 IDE 里都有类型推导。示例使用 OpenAI 当前主力模型 gpt-5.4,实际项目里可通过环境变量配置 API Key。

import { createTool } from "@mastra/core/tools";
import { Agent } from "@mastra/core/agent";
import { z } from "zod";

const getWeather = createTool({
  id: "get_weather",
  description: "根据城市名称查询当前天气,适合回答天气相关提问",
  inputSchema: z.object({
    location: z.string().describe("城市名称,如北京、上海"),
  }),
  outputSchema: z.object({ summary: z.string(), temp: z.number().optional() }),
  execute: async ({ location }) => {
    // 实际项目里这里调和风、OpenWeather 等 API
    return { summary: `${location} 晴`, temp: 22 };
  },
});

const weatherAgent = new Agent({
  id: "weather-agent",
  name: "天气助手",
  instructions: "你是天气助手,用 get_weather 查天气并简洁回复用户。",
  model: "openai/gpt-5.4",
  tools: { getWeather },
});

// 在 Next.js Route Handler 或 Server Action 里直接调用
const result = await weatherAgent.generate("北京今天天气怎么样?");
console.log(result.text);

在 Next.js 的 Route Handler 里暴露成 API 时,导入 weatherAgent,对请求体里的消息调 generate 或流式方法即可,不必再写状态机或图。

LangGraph:显式建图与状态

在 LangGraph 里,要先定义状态结构(例如消息列表)、再定义"agent"节点(调用模型、可能产生 tool_calls)和"tools"节点(执行工具并返回 ToolMessage),最后用边把节点串起来,并加上"是否继续调工具"的条件边。模型用 LangChain 的 ChatOpenAI 接 OpenAI 最新模型,工具用 bindTools 绑定,循环由图的拓扑自然形成。

下面这段示例用 StateGraph 定义了一个单 Agent、带一个天气工具的最小图。状态里只有 messages,agent 节点读最后一条用户消息并调用模型,若返回 tool_calls 则路由到 tools 节点,执行完再回到 agent,直到模型不再调工具为止。可与上面 Mastra 示例对照,体会"图"和"状态"的显式写法。

import { StateGraph, Annotation, END } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages";
import { ToolNode } from "@langchain/langgraph/prebuilt";

const model = new ChatOpenAI({
  model: "gpt-5.4",
  apiKey: process.env.OPENAI_API_KEY,
});

const getWeather = tool(
  async (input: { location: string }) => `${input.location} 晴,22℃`,
  {
    name: "get_weather",
    description: "根据城市名称查询当前天气",
    schema: z.object({ location: z.string() }),
  },
);

const modelWithTools = model.bindTools([getWeather]);
const toolNode = new ToolNode([getWeather]);

const AgentState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: (left, right) => left.concat(right),
    default: () => [],
  }),
});

async function agentNode(state: typeof AgentState.State) {
  const response = await modelWithTools.invoke(state.messages);
  return { messages: [response as AIMessage] };
}

function shouldContinue(state: typeof AgentState.State): "tools" | "end" {
  const last = state.messages[state.messages.length - 1] as AIMessage;
  return last.tool_calls?.length ? "tools" : "end";
}

const graph = new StateGraph(AgentState)
  .addNode("agent", agentNode)
  .addNode("tools", toolNode)
  .addEdge("tools", "agent")
  .addConditionalEdges("agent", shouldContinue, { tools: "tools", end: END })
  .compile();

const result = await graph.invoke({
  messages: [new HumanMessage("北京今天天气怎么样?")],
});
console.log(result.messages[result.messages.length - 1]);

同样实现"用户问天气、模型调工具、再回复":Mastra 是"Agent + tools 配置 + 一次 generate",LangGraph 是"状态注解 + 两节点 + 条件边 + compile"。前者适合快速落地和前端集成,后者适合加人工审核、多 Agent 分支、断点续跑等复杂控制。示例中 LangGraph 使用 gpt-5.4,API Key 建议从环境变量 OPENAI_API_KEY 读取。

选型决策思路

可以按"产品形态、团队基因、集成与定制需求"三条线问自己,再对照上文对比。决策主线就一条:先看 Agent 是"应用的核心"还是"应用里的辅助能力"。核心场景(多 Agent、长任务、状态中断与恢复、大量冷门集成或深度定制)更倾向 LangGraph;辅助能力(Next/React 为主、快速迭代、极重 TypeScript 与 DX)更倾向 Mastra。不必二选一,也可以简单对话用 Mastra、复杂管线用 LangGraph,按模块边界拆。

如下图所示。

20260314115134

从"核心还是辅助"出发,到倾向 LangGraph 或 Mastra(或两者组合)的决策路径。

更偏向选 Mastra 的情况

  • 产品形态上,Agent 主要作为 Web 应用的辅助功能(智能搜索、客服助手、简单数据总结等),且深度绑定 Next.js、React 生态。
  • 团队以前端、全栈为主,不想引入过重的后台架构,希望快速迭代、快速上线。
  • 你非常看重 TypeScript 的类型安全和开发体验,对臃肿依赖和难以排查的报错比较排斥。

更偏向选 LangChain / LangGraph 的情况

  • 产品形态上,核心业务就是一个复杂的 AI 系统,例如多 Agent 协作、长时间运行的异步任务、或需要精准控制思考中断与恢复。
  • 集成需求多且杂,需要连接内部各种老旧系统,或使用非常小众的向量数据库、模型接口。
  • 需要对底层 Prompt、重试、Memory 等做深度定制,甚至改动框架默认行为。

结合业务场景做更细的取舍

光看框架特性不够,最终要落到"这个 Agent 具体负责什么"上。下面四类典型场景方便对号入座,每类对应不同复杂度和集成需求,选错框架要么大材小用,要么后期自己造轮子。

如下图所示。

20260314115312

四类场景与推荐方向的对应关系。

场景一:官网或产品里的智能客服、搜索建议

用户在一页里问几句,要即时、简洁的回复,必要时查文档或知识库。流程短、状态简单,不需要多 Agent 博弈或断点续跑,前端发一条消息、收一条(或流式)回复,至多一两轮工具调用。这类需求 Mastra 的轻量 API 和 TypeScript 体验很顺手,一个 Agent 配几个工具、在 Route Handler 里调 generate 就能上线;用 LangGraph 容易杀鸡用牛刀,要先建图、理解条件边和状态,对只想做一个会查文档的客服的团队来说性价比不高。

场景二:内部工具里的"多步任务助手"

例如用自然语言帮用户订会议室、填工单、查数据并生成报告。步骤多,有时要人工确认或回退(如"是否确认提交工单"),状态要在多轮请求间保持,甚至支持"离开页面再回来从断点继续"。这类需求用 LangGraph 的状态图和 checkpointer 更自然:节点对应步骤或人工介入,边上挂条件判断,状态持久化后刷新或重连都能恢复。用 Mastra 也能做,但分支和人工介入一多,就得自己维护"当前步骤、待确认项、历史结果",等于在业务层再造状态机,不如直接用图建模,让框架负责持久化。

场景三:多 Agent 协作(检索、生成、审核等分工)

多角色各司其职,之间有固定或动态调用关系,甚至要循环几轮才产出结果。这类编排是 LangGraph 的强项;Mastra 更适合"一个主 Agent 调若干工具"的链式交互,多 Agent 的路由和状态共享要自己写胶水代码。

场景四:向量库、模型、外部 API 集成种类多

公司内有自建向量库、多种大模型和第三方 API,希望用同一套抽象管住"检索、调用、解析"。LangChain 的集成生态在这里优势明显:Pinecone、Weaviate、Qdrant、自建 REST、各类 LLM 与 RAG 预制链和图,大多有现成包。Mastra 偏向精选常用组合,技术栈若较"非主流"(内网模型、私有协议、冷门向量库),可能要自己写适配层,把外部能力包成 Mastra 能识别的工具或模型接口。要权衡多写的适配代码是否被 Mastra 的 DX 和轻量部署抵消;若集成种类还会持续增加,直接上 LangChain 生态往往更省事。

常见误区与落地注意点

选型时容易踩的坑和落地前值得想清楚的几点,简单归纳如下。

不必纠结的两点。第一,没有"用了 Mastra 就不能用 LangChain"这回事,两者可共存,例如边缘或 BFF 用 Mastra 做轻量对话,后台用 LangGraph 做复杂管线,用 HTTP 或消息队列打通。第二,没有"LangGraph 一定比 Mastra 重"的绝对结论,重的是你要维护的图与状态逻辑;若你只需要一张简单 agent-tools 图,编译后运行时开销可接受,主要是上手成本高。

需要提前想清楚的两点。一是"先简单后复杂"时,若判断半年内会演进到多 Agent 或人机协同,可早点把复杂子流程用 LangGraph 建模,哪怕先只实现单 Agent,图结构也为后续加节点留好位置,避免以后在 Mastra 里手写状态机再迁一轮。二是"先复杂后简化"时,若团队普遍抱怨 LangChain 报错难查、概念太多,可把"单轮或短对话"抽成独立服务,用 Mastra 重写,接口不变、前端无感,逐步降维护成本。

最后,无论选哪边,都建议一开始就把"输入输出契约"(请求体格式、流式 SSE、错误码)定好,并用 TypeScript 类型或 OpenAPI 描述出来,以后换实现、做 A/B 或拆服务时,前端和网关都不必大动。

混合使用与迁移成本

不少团队会折中:简单、面向用户的 Agent 用 Mastra,部署在前端或边缘;复杂、长链路、多 Agent 的管线放后端,用 LangGraph 或 LangChain 实现,通过 API 暴露。这样既保住前端侧的开发体验和性能,又在需要复杂编排时用上 LangChain 生态。

若一开始选了 Mastra,后面业务演进到必须上状态图、多 Agent,可以只把"复杂子流程"迁到 LangGraph,用 HTTP 或消息队列和现有 Mastra Agent 对接,不必全盘重写。例如前端仍用 Mastra 做即时问答,把多步审批、长任务编排单独做成 LangGraph 服务,Mastra 在需要时调该服务 API。反过来,若一开始用 LangChain 搭了简单客服,发现维护成本高、报错难排查,可以把单轮或短对话抽成独立服务,用 Mastra 重写,逐步迁移。关键是想清楚边界(按功能、按请求路径、按团队 ownership 都行),按边界拆模块,而不是非此即彼。迁移时优先保证输入输出契约稳定(统一 JSON 请求体、流式 SSE 格式),前端或网关就不必大改。

总结与下一步

Mastra 和 LangChain(LangGraph)代表两种设计哲学:前者为前端和 TypeScript 优化,追求轻量和 DX;后者面向通用 AI 编排和复杂状态,追求生态和表达能力。没有谁一定更好,只看和你的业务场景、团队结构、集成与定制需求是否匹配。

一句话记住选型心法:Agent 是"应用里的辅助能力"、团队偏前端、要快上线,优先看 Mastra;Agent 是"业务核心"、有多 Agent、长任务、人机协同或大量冷门集成,优先看 LangGraph。两者也可组合,按模块边界拆,契约定好即可。

建议先明确两件事:当前要做的 Agent 主要负责什么(辅助功能还是核心 AI 系统),以及半年到一年内会不会出现多 Agent、长任务、复杂集成或深度定制。有了这两个问题的答案,再对照文中的对比表、决策说明和四类场景,选型会清晰很多。若你愿意说一下目前在规划的 Agent 具体负责什么业务、会接哪些系统,可以在此基础上再做一轮更细的技术栈评估和落地方案设计。文中的代码示例使用当前主流的 gpt-5.4,可直接复制后按需改模型名和 API Key 配置。

尤雨溪宣布 Vite+ 正式开源,前端工具链要大一统了

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

如果你对 AI全栈 感兴趣,也欢迎添加我微信,我拉你进交流群

3 月 13 日深夜,尤雨溪在 X 上发了一条推文,平静地宣布了一件大事。

We are happy to announce that Vite+ is now fully open source under MIT license. Free for everyone!

20260315094604

Vite+ 以 MIT 协议全量开源,所有人免费使用。官网已经上线,地址是 viteplus.dev

如果说 Vite 8 的发布是"换了个引擎",那 Vite+ 的开源就是直接掀了桌子。它不是 Vite 的升级版,而是一个全新的物种,一个二进制文件,吃掉你整条前端工具链。

Vite+ 到底是什么

官网给出的定位很直白,"The Unified Toolchain for the Web"。

一句话来说,Vite+ 是一个统一的 Web 开发工具链,把 ViteVitestOxlintOxfmtRolldowntsdownVite Task 七个项目合并成了一个 CLI,命令叫 vp

它的野心不小。管构建,管运行时,管包依赖,管代码检查,管格式化,管测试,管打包发布,甚至管 monorepo 的任务编排。以前你需要 npmpnpmViteESLintPrettierJestnvm 各自配置、各自维护,现在一个 vp 全包了。

值得注意的是,Vite+ 是两段式设计:vp 是全局安装的命令行工具,vite-plus 是每个项目里安装的本地包。这两者协同工作,vp 负责统一入口,vite-plus 负责具体的构建逻辑。

image.png

完整命令地图

vp 的命令覆盖了开发全流程,分成几个维度来看:

启动和初始化

命令 做什么
vp create 创建新项目(支持 app、包、monorepo 模板)
vp migrate 把现有项目迁移到 Vite+
vp env 管理 Node.js 版本
vp install 用正确的包管理器安装依赖
vp config 配置 commit hooks 和 agent 集成

日常开发

命令 做什么 替代谁
vp dev 开发服务器,即时 HMR vite dev
vp check 类型检查 + Lint + 格式化 tscESLintPrettier
vp lint 单独运行 Lint ESLint
vp fmt 单独运行格式化 Prettier
vp test 运行测试 JestVitest
vp staged 对暂存文件跑检查 lint-staged

构建和发布

命令 做什么 替代谁
vp build 生产构建 vite build
vp preview 本地预览生产构建 vite preview
vp pack 库打包 + DTS 生成 tsuptsdown
vp run monorepo 任务执行(带缓存) turboreponx

依赖管理

命令 做什么
vp add / vp remove / vp update 包管理操作
vp dedupe / vp outdated / vp why 依赖分析
vp dlx 不安装直接运行包(类似 npx
vpx 全局执行二进制

还有一个彩蛋命令,vp implode,它会把 vp 本身和所有相关数据从机器上清除干净,如果用了之后觉得不适合自己,一条命令可以走得一干二净。

官网 viteplus.dev 首页的终端示例里,用 vp create acme-web --template react-ts 创建 React + TypeScript 项目,从脚手架生成到依赖安装完成,显示耗时 1.1 秒。

性能数字很夸张

Vite+ 的底层全部用 Rust 重写,官方给出的性能对比数据:

  • 生产构建比 webpack 快 40 倍(基于 Vite 8 + Rolldown
  • OxlintESLint 快 50 到 100 倍
  • OxfmtPrettier 快 30 倍
  • 开发时 HMR 始终保持即时响应

这些数字不是 Vite+ 团队自己编的。OxlintOxfmtOxc 项目里已经跑了很久的 benchmark,社区早有验证。Vite+ 做的事是把这些分散的高性能工具统一到了一个入口。

vp check 不只是 Lint

vp check 是这个工具链里设计最有意思的命令之一,值得单独说说。

它把三件事合进一个命令:Oxfmt 负责格式化,Oxlint 负责代码检查,tsgolint 负责 TypeScript 类型检查。三个工具并行跑,比分别执行快得多。

当你在 vite.config.ts 里开启 typeCheck 选项后,vp check 还会接入 TypeScript Go 工具链做类型感知的静态分析,这是微软正在推进的下一代 TypeScript 编译器,速度比原来的 tsc 快了一个数量级。

import { defineConfig } from 'vite-plus'

export default defineConfig({
  lint: {
    options: {
      typeAware: true,
      typeCheck: true,
    },
  },
})

开启之后,一条 vp check 就能搞定格式、Lint、类型三重检查。加上 --fix 参数还能自动修复可修复的问题:

vp check        # 检查
vp check --fix  # 检查并自动修复

一个配置文件管所有

以前的前端项目,配置文件能铺满项目根目录,vite.config.ts.eslintrc.prettierrcvitest.config.tstsconfig.jsonlint-staged.config.js……

Vite+ 的做法是把所有配置收拢到一个 vite.config.ts

import { defineConfig } from 'vite-plus'

export default defineConfig({
  // 开发服务器
  server: { port: 3000 },

  // Oxlint 规则
  lint: {
    options: {
      typeAware: true,
      typeCheck: true,
    },
  },

  // Oxfmt 格式化
  fmt: { /* ... */ },

  // Vitest 测试
  test: { /* ... */ },

  // 任务编排
  tasks: { /* ... */ },

  // commit 前的暂存检查(替代 lint-staged)
  staged: {
    '*.{js,ts,tsx,vue,svelte}': 'vp check --fix',
  },

  // 库打包(替代 tsdown.config.ts)
  pack: {
    entry: ['src/index.ts'],
    dts: true,
    format: ['esm', 'cjs'],
  },
})

一个文件,一套类型提示,一个 IDE 插件搞定所有配置的智能补全。对于强迫症开发者来说,这可能比性能提升更让人兴奋。

vp env 能精细管理 Node 版本

nvm 的用户应该对这种场景很熟悉,不同项目需要不同版本的 Node,切换还容易忘。

vp env 的设计是让 nodenpm 等命令都通过 Vite+ 的 shim 来走,自动识别当前项目锁定的 Node 版本,无需手动切换。

常用命令:

vp env pin lts          # 把项目锁定到最新 LTS 版本(写入 .node-version)
vp env use 20           # 当前 shell 会话临时切换到 Node 20
vp env default lts      # 设置全局默认版本
vp env current          # 查看当前解析到的环境
vp env doctor           # 运行环境诊断,排查问题
vp env list             # 列出本地已安装的版本
vp env list-remote --lts  # 查看可安装的 LTS 版本列表

如果你不想让 Vite+ 接管 Node 版本管理,可以用 vp env off 切到"系统优先"模式,Vite+ 只在系统 Node 找不到时才接管。

现有项目怎么迁移

这是官网里最有价值的部分之一,也是原文没有覆盖到的内容。

对于已有的 Vite 项目,迁移命令是:

vp migrate

这条命令会自动完成:把各个工具的分散配置合并进 vite.config.ts,更新项目依赖,重写 vitevitest 的导入路径,更新 package.json 里的 scripts。

官方建议的迁移前准备:先升级到 Vite 8+ 和 Vitest 4.1+,了解现有的 Lint、格式化、测试配置。迁移后跑一遍验证:

vp install
vp check
vp test
vp build

有意思的一个细节,官网的迁移文档里提供了一段专门写给 AI 编码助手的 migration prompt,可以直接粘贴给 Cursor 或 Claude 来代劳整个迁移过程。这说明 Vite+ 团队在设计工具时已经把 AI 辅助开发纳入考虑了。

不止是 Vue 生态的事

Vite+ 支持的框架列表相当长,包括 ReactVueSvelteSolidAstroNuxtNext.jsRemix,官网列了超过 20 个框架。

这意味着它不是"Vue 生态的专属工具"。任何前端框架的开发者都可以用,而且迁移成本几乎为零,因为底层就是 Vite,现有的 Vite 插件理论上都能直接用。

部署方面,Vite+ 可以与 Nitro 配合,直接部署到 VercelNetlifyCloudflareRender 等平台,从 SPA 到全栈 meta 框架都有完整支持。

怎么装

macOS 或 Linux 下:

curl -fsSL https://vite.plus | bash

Windows(PowerShell)下:

irm https://vite.plus/ps1 | iex

装完就是一个独立二进制文件,不依赖 Node.js 全局安装,不需要 npm install -g。安装后打开新的终端窗口,运行 vp help 就能看到所有命令。在 CI 环境里可以用官方提供的 setup-vp Action。

运行 vp upgrade 可以更新 vp 本身到最新版本。

谁在做这件事

Vite+ 背后是 VoidZero,尤雨溪在 2024 年创立的公司,专注于 Web 工具链。核心团队成员里有几个名字值得关注:

  • 尤雨溪,Vue.jsVite 的创造者
  • LONG Yinan,Oxc 项目的核心作者,Rust 工具链领域的资深开发者
  • Christoph Nakazawa,前 Meta 工程师,Jest 的创造者

没错,Jest 的创造者现在在给 Vite+ 写测试框架。这个阵容不需要多解释。

GitHub 仓库显示,Vite+ 的代码库有 608 个 commit,62.9% 是 Rust,33.4% 是 TypeScript。目前最新版本是 v0.1.11,处于 Alpha 阶段。

Vite 本身每周 npm 下载量已达 6900 万次,GitHub 星标 78.7K,是前端构建工具的事实标准。Vitest 每周下载量也超过 3500 万。这套工具链的用户基数不需要从零积累。

商业模式

很多人关心的问题,这么大的项目,免费能持续多久?

VoidZero 的做法是,Vite+ 完全开源,MIT 协议,永久免费。公司的营收来源是另一个独立的商业产品 Void,具体形态还没公开,但大概率是面向企业的增强版或云服务。

这和 VercelNext.js 免费,平台收费)的路线类似,开源工具做增长飞轮,商业产品做营收。这条路已经被验证过了。

现阶段要注意的几点

虽然 Vite+ 的愿景很性感,但当前有几个现实问题值得正视。

第一,它现在是 Alpha 版本。v0.1.11,连 Beta 都没到,API 可能随时调整,生产环境请三思。官方文档里也明确说了,vp migrate 运行完之后大多数项目还需要手动调整,不是一键无缝。

第二,"大一统"是双刃剑。统一工具链的好处是减少配置和兼容性问题,但坏处是一旦某个模块出问题,整条链都可能受影响。以前 ESLint 出错不影响构建,以后就不好说了。

第三,生态兼容性需要时间。虽然理论上兼容 Vite 插件,但实际使用中肯定会有各种边界情况,社区插件的适配需要一个过程。

第四,包管理这块水很深。npmpnpmyarn 打了很多年,每家都有自己的 resolve 策略和 lockfile 格式,Vite+ 要在这个领域站稳脚跟,挑战不小。

这件事的意义

前端工具链的碎片化问题困扰社区很久了。一个新项目光配置工具链就要半天,node_modules 动辄几百 MB,各种工具之间的版本冲突是家常便饭。

Vite+ 的出现代表了一种趋势,用 Rust 重写性能敏感的部分,用统一的入口消除工具之间的缝隙。

类似的尝试不止 Vite+ 一家,BunDenoBiome 都在做类似的事。但 Vite+ 有一个独特优势,它站在 Vite 的肩膀上,从 ViteVite+ 的迁移路径是最短的,用户基数也是最大的。

从现在的角度来看,Alpha 阶段先关注、多试用、遇到问题提 issue 才是正确姿势。但这件事本身值得认真看待,前端工具链可能真的要变了。

📖 2026年 大厂前端面试手写题库已开源(2.3k star)

前端手写题集锦 use js 记录大厂笔试,面试常考手写题, 致力打造最全的前端JavaScript手写题题库和答案的最优解

Github:github.com/Sunny-117/j…

谢谢您的star,您的star是我更新的动力🥳

里面有答案,为了让你们有一个参考,不过非常希望你们能提供自己的思路,指出答案中存在的问题,复杂度优化等等, 期待你们的contribute, 想来一起维护这个项目,可以联系我,成为contributor

主要是让大家讨论出最优解,然后merge,一起贡献这个项目,有些答案有点问题,所以我给出的答案仅作参考,也欢迎发现的小伙伴提PR

贡献此项目

提PR就行

思考很久,用issue形式收集各种手写题,并让小伙伴们讨论题解

JavaScript HOT 100 题

中大厂面试,最常考的100个题,每一题都非常具有代表性,想要准备面试突击的同学,优先看这些题,祝在座的每一位都能拿到满意的offer

实现 Promise (hot)

Promise 周边场景题(hot)

JavaScript 常考手写题

设计模式相关

树-场景题(hot)

实现 JS 原生方法

JS 库函数实现

js utils

手写 nodejs 模块

正则相关

排序算法

实现自定义HOOK

组件设计题(Vue/React/JS均可)

HTML CSS 手写题

React 更新触发原理详解

核心结论(面试开篇必说):React 的更新触发本质上是 状态(State)/属性(Props)/上下文(Context)发生变化 后,React 调度组件重新渲染的过程。简单说,就是“依赖的数据变了,React 就会重新渲染组件,更新页面”。下面从「触发源」「执行流程」「关键细节(避坑点)」「核心底层逻辑」「面试常考问题」五个维度,讲透更新触发的全流程,兼顾通俗理解和专业表述,适合面试直接背诵。

一、更新的核心触发源(4类,重中之重)

React 组件不会“无缘无故”更新,核心原因是「它依赖的数据变了」。这4类触发场景,面试必问,务必记牢,结合示例理解更易背诵。

1. 状态(State)变化(最核心、最常见)

通俗说:组件自己“内部的数据”变了,就会触发更新。比如计数器的数字、表单的输入值,都是 State,修改它们就会让组件重新渲染。

专业表述:通过 React 提供的「状态更新函数」修改组件内部状态,是触发更新的首要方式,分类组件和函数组件两种写法。

  • 类组件:调用 this.setState()(推荐)或 this.forceUpdate()(强制更新,不推荐)。

    • 关键细节:setState异步的(合成事件、生命周期钩子中),React 会批量处理多次 setState,避免频繁渲染(比如连续调用2次 setState,只会渲染1次)。

    • 面试可用示例(简洁好记):

    class Counter extends React.Component {
      state = { count: 0 };
      handleClick = () => {
        // 触发更新:修改state后,组件重新执行render
        this.setState({ count: this.state.count + 1 });
      };
      render() {
        return <button onClick={this.handleClick}>{this.state.count}</button>;
      }
    }
    
  • 函数组件:调用useState 返回的更新函数,或 useReducerdispatch 方法(复杂状态管理用)。

    • 关键细节:和类组件的 setState 类似,更新函数也是异步批量处理,避免无效渲染。

    • 面试可用示例(简洁好记):

    function Counter() {
      const [count, setCount] = React.useState(0);
      const handleClick = () => {
        // 触发更新:调用setCount后,组件重新执行
        setCount(count + 1);
      };
      return <button onClick={handleClick}>{count}</button>;
    }
    

2. 属性(Props)变化(父子组件通信相关)

通俗说:父组件给子组件“传的数据”变了,子组件就会跟着更新(除非手动阻止)。比如父组件传一个“用户名”给子组件,用户名变了,子组件就会重新渲染显示新的用户名。

专业表述:父组件传递给子组件的 Props 发生变化时,子组件会触发更新;父组件自身更新时,会重新计算子组件的 Props,即使 Props 看起来没变化(比如传递新的对象/函数引用),子组件也会默认更新。

面试可用示例(简洁好记):

// 父组件更新 → 子组件Props变化 → 子组件更新
function Parent() {
  const [name, setName] = React.useState("React");
  return (
    <div>
      <button onClick={() => setName("Vue")}>修改名称</button>
      <Child name={name} /> // 父组件name变了,子组件Props变化
    </div>
  );
}
function Child({ name }) {
  // 父组件修改name后,这里会重新渲染
  return <div>名称:{name}</div>;
}

3. 上下文(Context)变化(跨组件通信相关)

通俗说:多个组件共享的“全局数据”变了,所有用到这个数据的组件都会更新。比如全局主题(浅色/深色),切换主题后,所有使用主题的组件都会重新渲染。

专业表述:组件通过 useContext(函数组件)或 Context.Consumer(类组件)订阅了上下文,当上下文的 Providervalue 发生变化时,所有订阅该上下文的组件都会触发更新。

面试可用示例(简洁好记):

const ThemeContext = React.createContext();
function Parent() {
  const [theme, setTheme] = React.useState("light");
  return (
    <ThemeContext.Provider value={theme}> // 提供上下文数据
      <button onClick={() => setTheme("dark")}>切换主题</button>
      <Child /> // 子组件订阅上下文
    </ThemeContext.Provider>
  );
}
function Child() {
  // 上下文变化 → 组件更新
  const theme = React.useContext(ThemeContext);
  return <div>当前主题:{theme}</div>;
}

4. 其他特殊触发方式(面试易考补充)

这类场景不常用,但面试常问“还有哪些方式能触发更新”,记3个核心即可:

  • useState/useReducer 的更新函数接收「函数参数」时,即使最终值未变化,也会触发更新(但 React 会跳过无变化的渲染,不会更新真实 DOM);

  • 类组件 this.forceUpdate():强制触发更新,跳过 shouldComponentUpdate 检查(不推荐,会导致不必要的渲染);

  • React 18+ 新增 useSyncExternalStore:用于订阅外部数据源(如 Redux、localStorage),当外部数据源变化时,触发组件更新。

二、更新的执行流程(简化版,面试直接背)

核心口诀:调度 → 渲染 → 提交(3步走,通俗+专业结合,好记不绕)

触发更新后(比如调用 setState),React 不会立刻更新页面,而是按以下步骤有序执行,核心是“高效更新,只更变化的部分”:

1. 调度(Schedule):排优先级,入队列

通俗说:React 收到更新请求后,先判断“这个更新有多紧急”,比如用户点击按钮(高优先级)要立刻响应,定时器回调(低优先级)可以缓一缓,然后把更新请求加入调度队列,按优先级排序。

专业表述:React 接收到更新请求后,根据更新的优先级(由 Lane 机制标记),将更新加入调度队列,优先处理高优先级更新,避免卡顿(比如用户交互不会被低优先级更新阻塞)。

2. 渲染(Render):生成虚拟DOM,做Diff对比

通俗说:React 从触发更新的组件开始,像“查家谱”一样,递归遍历整个组件树,生成一份新的“虚拟DOM”(可以理解为页面的“虚拟蓝图”),然后和旧的虚拟DOM对比,找出“不一样的地方”(也就是需要更新的部分)。

专业表述:从触发更新的组件出发,递归遍历组件树,执行组件的 render 方法(函数组件直接执行组件本身),生成新的虚拟 DOM(VNode);通过 React 的 Diff 算法(协调算法,Reconciliation)对比新旧虚拟 DOM,找出最小更新集(只更新变化的节点,不更新整个页面)。

3. 提交(Commit):更新真实DOM,执行副作用

通俗说:React 把 Diff 对比找到的“变化部分”,应用到真实的页面上(也就是更新浏览器的 DOM),完成页面更新;同时执行一些“副作用”,比如类组件的生命周期、函数组件的 useEffect。

专业表述:将 Diff 算法的结果应用到真实 DOM 上,完成页面更新;此时类组件会执行 componentDidUpdate 生命周期钩子,函数组件会执行 useEffect(只有依赖项发生变化时才会执行)。

三、关键细节(避坑点,面试高频提问)

这部分是面试“拉开差距”的地方,不仅要记,还要能说清“为什么”和“怎么解决”,结合场景记忆。

1. setState 的异步特性(必考)

核心问题:为什么调用 setState 后,立刻打印 this.state,拿到的还是旧值?

通俗解释:React 为了提高性能,会把多个 setState 合并成一次更新,所以在合成事件(比如 onClick、onChange)、生命周期钩子(比如 componentDidMount)中,setState 是异步的,不会立刻更新 state。

特殊情况:在原生事件(比如 addEventListener 绑定的事件)、定时器(setTimeout、setInterval)中,setState 是同步的,能立刻拿到最新 state。

解决方法(面试必说):用 setState 的「函数形式」,接收 prevState(上一次的状态)作为参数,就能拿到最新的 state:

// 正确写法,能拿到最新state
this.setState(prevState => ({ count: prevState.count + 1 }));
// 错误写法,可能拿到旧值(异步场景下)
this.setState({ count: this.state.count + 1 });

2. 避免不必要的更新(性能优化,必考)

核心问题:如何减少 React 组件的无效渲染?(比如父组件更新,子组件没变化也跟着更新)

分组件类型给出解决方案(通俗+专业,好记):

  • 函数组件:用 React.memo 包裹组件,它会浅比较 Props,Props 没变化就不会重新渲染;

  • 类组件:重写 shouldComponentUpdate 钩子,手动判断 Props/State 是否变化,返回 true 才更新,返回 false 阻止更新;

  • 通用优化:传递 Props 时,避免创建新的引用(比如不要在 Props 中直接写箭头函数、新建对象),用 useCallback 缓存函数、useMemo 缓存对象/计算结果。

3. React 18+ 批量更新(新增考点)

核心变化:React 18 之前,只有合成事件、生命周期中会批量更新;React 18 之后,默认对所有更新(包括定时器、原生事件中)进行批量处理,进一步减少渲染次数。

特殊需求:如果需要同步更新(比如更新后立刻获取 DOM 信息),用ReactDOM.flushSync() 包裹更新操作:

import ReactDOM from 'react-dom';

// 同步更新,执行完setState后,能立刻拿到最新DOM
ReactDOM.flushSync(() => {
  setCount(count + 1);
});

四、核心底层逻辑(面试拔高,不用看源码,直接背)

面试常问:setState / dispatch 到底做了什么?(不用讲源码,说清逻辑顺序即可,记下面这段,直接背诵)

核心逻辑(分4步,清晰好记):

  1. 调用 setState(或 dispatch)后,React 会创建一个「update 对象」(记录更新的内容、优先级等信息);

  2. 将这个 update 对象放入「更新队列(updateQueue)」中;

  3. 通过「Lane 机制」给这个更新标记优先级(高优先级优先执行);

  4. React 调度器(Scheduler)触发渲染流程,开始执行“调度 → 渲染 → 提交”的步骤。

总结一句(面试必说):setState 本身不会立刻更新 state,它只是创建一个更新请求,React 会根据优先级统一调度,批量处理更新,最终完成组件渲染和 DOM 更新

五、面试常考问题(直接背诵答案,覆盖90%考点)

以下问题,直接记答案,面试时直接回答,不用临场组织语言,高效得分。

1. 问:React 组件更新的触发条件有哪些?

答:核心是依赖的数据发生变化,主要有4类:① State 变化(调用 setState、useState 更新函数、useReducer 的 dispatch);② Props 变化(父组件传递的 Props 改变,或父组件更新导致 Props 重新计算);③ Context 变化(订阅的 Context.Provider 的 value 变化);④ 特殊方式(forceUpdate、useSyncExternalStore、useState/useReducer 函数参数触发的更新)。

2. 问:setState 是同步还是异步的?为什么?

答:分场景:① 合成事件(onClick 等)、生命周期钩子中,setState 是异步的;② 原生事件、定时器中,setState 是同步的。原因:React 为了优化性能,会批量处理多个 setState 请求,避免频繁渲染,所以在异步场景下会延迟更新 state,合并多次更新。

3. 问:如何解决 setState 异步导致的“拿不到最新 state”问题?

答:使用 setState 的函数形式,接收 prevState 作为参数,prevState 是上一次的最新状态,通过它计算新状态,就能确保拿到最新值,示例:this.setState(prevState => ({ count: prevState.count + 1 }))。

4. 问:父组件更新,子组件一定会更新吗?如何避免不必要的更新?

答:不一定。父组件更新时,会重新计算子组件的 Props,即使 Props 没变化(比如传递新的函数/对象引用),子组件也会默认更新。避免方法:① 函数组件用 React.memo 包裹,浅比较 Props;② 类组件重写 shouldComponentUpdate 钩子,手动判断是否更新;③ 用 useCallback 缓存函数、useMemo 缓存对象,避免传递新引用。

5. 问:React 更新的执行流程是什么?

答:核心3步:① 调度(Schedule):接收更新请求,标记优先级,加入调度队列;② 渲染(Render):递归遍历组件树,生成新虚拟 DOM,通过 Diff 算法对比新旧虚拟 DOM,找出变化部分;③ 提交(Commit):将变化应用到真实 DOM,执行副作用(componentDidUpdate、useEffect)。

6. 问:React 18 中批量更新有什么变化?

答:React 18 之前,只有合成事件、生命周期中支持批量更新;React 18 之后,默认对所有场景(包括定时器、原生事件)进行批量更新,减少渲染次数。如需同步更新,可用 ReactDOM.flushSync() 包裹更新操作。

7. 问:useState 和 setState 的区别?(延伸考点)

答:① 用法不同:useState 用于函数组件,返回 [state, 更新函数];setState 用于类组件,是 this 的方法;② 状态更新方式不同:useState 的更新函数是直接替换状态(不会合并),setState 会自动合并同名状态;③ 异步特性一致:两者在合成事件、生命周期中都是异步的,原生事件、定时器中是同步的。

总结

React 更新的核心是“依赖数据变化触发调度渲染”,记住3个核心:① 触发源(State/Props/Context 为主);② 执行流程(调度→渲染→提交);③ 优化点(避免无效更新、理解 setState 异步)。

⏰前端周刊第 456 期(v2026.3.15)

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

image.png 💬 推荐语

本期关键词是“原生能力回归 + 架构复杂度再评估”。一方面,popoverdialog、Anchor Positioning、backdrop-filter、外部 import maps 等原生能力持续补齐,前端可以用更少的自定义代码获得更完整的交互与布局能力;另一方面,围绕 SPA vs. Hypermedia、前端内存泄漏、大规模 TypeScript 迁移、Next.js 在高延迟市场中的实践、React Foundation 治理变化等文章,也在提醒我们重新审视性能、复杂度、组织协作与平台边界。AI 方向上,NestJS + Gemini、React Activity、Pinia Colada 与 Angular 服务设计等内容,则进一步展现了现代前端如何在“更轻的平台能力”和“更强的工程抽象”之间找到平衡。


🗂 本期精选目录

🧭 Web 开发

🎨 CSS

💡 JavaScript

⚛️ React

🟢 Vue

🔴 Angular

Next.js 14 App Router 踩坑实录:5 个让我加班到凌晨的坑 🕳️

上个月把公司一个老项目从 Pages Router 迁到 App Router,本来觉得最多两天搞定,结果整整折腾了一周。中间遇到的坑,有的是文档没写清楚,有的是我自己想当然,有的纯粹是 Next.js 的行为跟直觉不一样。趁记忆还新鲜,全部记下来。

先说结论

严重程度 解决耗时 一句话总结
Server/Client Component 边界搞混 ⭐⭐⭐⭐⭐ 2天 默认是 Server Component,useState 直接炸
layout.tsx 不会重新渲染 ⭐⭐⭐⭐ 半天 切路由时 layout 状态不重置
metadata 导出和 'use client' 冲突 ⭐⭐⭐ 2小时 Client Component 不能导出 metadata
fetch 默认缓存策略 ⭐⭐⭐⭐ 1天 数据死活不更新,原来是被缓存了
动态路由 generateStaticParams 的坑 ⭐⭐⭐ 半天 build 时报错,运行时又正常

背景:为什么要迁移

项目是一个内部运营后台,之前用 Next.js 13 Pages Router 写的,功能不复杂,大概三十多个页面。迁移的直接原因是要加几个新功能,同事说「反正要改,不如一步到位上 App Router」。

说实话我一开始是拒绝的。Pages Router 用得好好的,干嘛折腾?但 Server Component 确实有吸引力——直接在组件里查数据库,不用写 API 路由了。行吧,干。

坑一:Server Component 和 Client Component 的边界

这是最大的坑。

App Router 下所有组件默认是 Server Component,不能用 useStateuseEffectonClick 这些东西。要用就得在文件顶部加 'use client'

道理我都懂,实际写起来完全是另一回事。

第一个炸的地方

迁移一个列表页,原来的代码大概长这样:

// app/dashboard/users/page.tsx
import { useState } from 'react'

export default function UsersPage() {
  const [search, setSearch] = useState('')
  const [users, setUsers] = useState([])

  // ... 省略 fetch 逻辑

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} />
      <UserList users={users} />
    </div>
  )
}

直接报错:

You're importing a component that needs useState. It only works in a Client Component 
but none of its parents are marked with "use client"

好,加 'use client'。加完之后这个页面就完全变成客户端渲染了,Server Component 的好处全没了。

正确的拆法

折腾了一天才想明白,关键是把交互逻辑拆到子组件里,页面本身保持 Server Component

// app/dashboard/users/page.tsx(Server Component,不加 'use client')
import { prisma } from '@/lib/prisma'
import { UserSearch } from './user-search'

export default async function UsersPage() {
  // 直接在组件里查数据库,这就是 Server Component 的好处
  const users = await prisma.user.findMany({
    take: 50,
    orderBy: { createdAt: 'desc' }
  })

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">用户管理</h1>
      {/* 把需要交互的部分拆成 Client Component */}
      <UserSearch initialUsers={users} />
    </div>
  )
}
// app/dashboard/users/user-search.tsx(Client Component)
'use client'

import { useState, useMemo } from 'react'
import type { User } from '@prisma/client'

interface Props {
  initialUsers: User[]
}

export function UserSearch({ initialUsers }: Props) {
  const [search, setSearch] = useState('')

  const filtered = useMemo(() => {
    if (!search.trim()) return initialUsers
    return initialUsers.filter(u =>
      u.name?.toLowerCase().includes(search.toLowerCase()) ||
      u.email?.toLowerCase().includes(search.toLowerCase())
    )
  }, [search, initialUsers])

  return (
    <div>
      <input
        className="border rounded px-3 py-2 mb-4 w-full max-w-md"
        placeholder="搜索用户名或邮箱..."
        value={search}
        onChange={e => setSearch(e.target.value)}
      />
      <table className="w-full">
        <thead>
          <tr>
            <th className="text-left p-2">ID</th>
            <th className="text-left p-2">姓名</th>
            <th className="text-left p-2">邮箱</th>
          </tr>
        </thead>
        <tbody>
          {filtered.map(user => (
            <tr key={user.id} className="border-t">
              <td className="p-2">{user.id}</td>
              <td className="p-2">{user.name}</td>
              <td className="p-2">{user.email}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

页面首屏服务端渲染带数据,搜索交互在客户端完成。

经验法则:能不加 'use client' 就不加,需要交互的部分拆成最小的子组件。

坑二:layout.tsx 切路由不重新渲染

这个坑隐蔽得多。

我在 layout 里放了个侧边栏,侧边栏上有「当前模块」的高亮状态,用 useState 管理。结果发现点击不同菜单,URL 变了,页面内容也变了,但侧边栏高亮不对。

原因:App Router 的 layout 在同级路由切换时不会卸载重建,状态会保留。 这是设计如此,不是 bug。文档里写了,但很容易略过。

解决方案是别用 useState 管这个状态,改用 usePathname() 直接读当前路径:

// components/sidebar.tsx
'use client'

import Link from 'next/link'
import { usePathname } from 'next/navigation'

const menuItems = [
  { href: '/dashboard', label: '概览' },
  { href: '/dashboard/users', label: '用户管理' },
  { href: '/dashboard/orders', label: '订单管理' },
  { href: '/dashboard/settings', label: '系统设置' },
]

export function Sidebar() {
  const pathname = usePathname()

  return (
    <nav className="w-60 bg-gray-50 min-h-screen p-4">
      {menuItems.map(item => {
        // 用 pathname 判断高亮,不依赖任何 state
        const isActive = pathname === item.href ||
          (item.href !== '/dashboard' && pathname.startsWith(item.href))

        return (
          <Link
            key={item.href}
            href={item.href}
            className={`block px-3 py-2 rounded mb-1 ${
              isActive
                ? 'bg-blue-500 text-white'
                : 'text-gray-700 hover:bg-gray-200'
            }`}
          >
            {item.label}
          </Link>
        )
      })}
    </nav>
  )
}

记住:layout 里的状态跨路由持久化,需要随路由变化的东西用 usePathnameuseSearchParams 驱动,别用 useState。

坑三:metadata 和 'use client' 不能共存

给每个页面设置 title 和 description,Next.js 14 的方式是导出 metadata 对象或 generateMetadata 函数:

// 这样写没问题
export const metadata = {
  title: '用户管理 - 后台',
  description: '管理系统用户'
}

export default async function UsersPage() {
  // ...
}

但如果这个文件加了 'use client',metadata 导出直接被忽略——不报错,不生效,你根本不知道它没工作。

这也是坑一重要的另一个原因:页面级组件保持 Server Component,metadata 才能正常导出。 需要交互的往下拆。

如果整个页面确实必须是 Client Component(比如复杂表单页),把 metadata 放到同目录的 layout.tsx 里,或者用父级 layout 的 generateMetadata 根据路径动态生成。

坑四:fetch 默认缓存,数据死活不更新

这个坑让我怀疑了整整一天。

在 Server Component 里 fetch 了一个内部 API 拿配置数据,第一次加载正常。然后我去数据库改了数据,刷新页面——没变。清缓存刷新——还是没变。重启 dev server——变了。

原因是 Next.js 14 的 fetch 在 Server Component 里默认开启缓存(相当于 cache: 'force-cache')。dev 模式下表现有时还不一致,更迷惑人。

// ❌ 默认被缓存,数据不会实时更新
const res = await fetch('https://api.example.com/config')

// ✅ 方案一:每次请求都重新获取
const res = await fetch('https://api.example.com/config', {
  cache: 'no-store'
})

// ✅ 方案二:设置过期时间(ISR 的效果)
const res = await fetch('https://api.example.com/config', {
  next: { revalidate: 60 }  // 60 秒后过期
})

// ✅ 方案三:页面级别设置(影响整个页面的所有 fetch)
export const dynamic = 'force-dynamic'  // 等价于每个 fetch 都 no-store
// 或
export const revalidate = 60  // 页面级 ISR

后台系统这种数据实时性要求高的,建议直接在 layout 或 page 里设 export const dynamic = 'force-dynamic',省得一个个 fetch 去配。面向用户的前台再按需用 revalidate 做 ISR。

另外,如果用的是 Prisma 直接查数据库(不走 fetch),上面这些缓存策略不生效。Prisma 查询不经过 Next.js 的 fetch 缓存层,要控制缓存得用 unstable_cache 或者 React 的 cache 函数,又是另一个话题了。

坑五:generateStaticParams 的玄学行为

动态路由 [id] 配合 generateStaticParams 做静态生成,build 的时候遇到了诡异问题。

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await prisma.post.findMany({
    select: { slug: true }
  })
  return posts.map(post => ({ slug: post.slug }))
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await prisma.post.findUnique({
    where: { slug: params.slug }
  })

  if (!post) notFound()

  return <article>{post.content}</article>
}

build 报错说数据库连不上,但 next dev 跑得好好的。

排查了半天,是 build 环境的 .env 没加载到正确的数据库连接串。这不是 Next.js 的锅,但 App Router 在 build 时会真正执行 generateStaticParams 去预渲染页面,踩过 Pages Router 的 getStaticPaths 就不陌生。

还有个更隐蔽的问题:Next.js 14 中 params 在某些情况下是个 Promise。 升级到较新版本可能需要这样写:

// 新版本需要 await params
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  // ...
}

这个变更文档里提了一句。如果从 13 直接升上来,大概率会被坑:TypeScript 会报类型错误,但没开严格模式的话,运行时可能直接拿到一个 Promise 对象当 string 用,查不到数据,返回 404,你还纳闷数据明明在数据库里。

额外收获:几个迁移小技巧

1. 渐进式迁移

App Router 和 Pages Router 可以共存。/app 下的路由优先级高于 /pages,所以可以一个页面一个页面地迁,不用一把梭。

2. loading.tsx 白送 Suspense

路由目录下放一个 loading.tsx,Next.js 自动帮你包 <Suspense>。页面里的异步数据加载期间会显示 loading 内容,不用手动写 Suspense 边界:

// app/dashboard/users/loading.tsx
export default function Loading() {
  return (
    <div className="p-6 animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-48 mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  )
}

3. error.tsx 也是自动的

同理,放一个 error.tsx 自动充当 Error Boundary,Server Component 和 Client Component 的错误都能兜住。记得加 'use client',Error Boundary 必须是客户端组件。

小结

迁完回头看,App Router 的心智模型确实比 Pages Router 复杂,但收益是实打实的——服务端组件直接查库省掉 API 层、自动 Streaming SSR、嵌套 Layout。新项目我会直接用 App Router,老项目就看情况,别像我一样低估迁移成本。

核心就一条:想清楚每个组件是 Server 还是 Client,画好边界线,其他问题都是小问题。

迁移清单放这了,有同样计划的可以参考着来。

前端性能优化-图片懒加载技术

前端性能优化:图片懒加载全攻略,3种实战方案+避坑详解

在前端性能优化体系中,图片资源往往是页面加载的“重灾区” ——电商列表、资讯长文、相册类页面,动辄十几张甚至上百张图片,若全量一次性加载,不仅拖慢首屏渲染、抢占带宽,还会造成大量无效请求。

图片懒加载作为针对性极强的优化手段,核心逻辑是非首屏图片延迟加载,进入可视区域再请求真实资源,既能大幅降低首屏加载耗时,又能节省流量、提升页面流畅度,更是优化 LCP、CLS 等 Core Web Vitals 核心指标的关键。

本文专注拆解图片懒加载,从原理、适用场景、3种落地实现方案,到避坑指南、效果验证

一、先理清:图片懒加载的核心原理

图片懒加载没有复杂底层逻辑,本质是 “阻断默认加载 + 监听可视状态 + 动态替换资源” 的闭环流程,针对浏览器默认自上而下加载图片的机制做优化:

  1. 标记占位:不直接将图片真实地址放入 src 属性(避免默认加载),改用 data-src等自定义属性存储真实地址,src 填充占位图(loading图、纯色占位、极小缩略图);
  2. 监听状态:监听页面滚动、元素位置,判断图片是否进入浏览器可视区域;
  3. 加载资源:满足可视条件后,将 data-src 中的真实地址赋值给 src,完成图片加载,同时移除监听避免重复执行。

简单来说:先用占位图“糊弄”浏览器,等用户快看到图片时,再加载真实图片。

二、图片懒加载的适用场景

图片懒加载并非所有场景都适用,精准落地以下场景,优化收益最大化:

  • 长页面图片列表:电商商品页、资讯文章、瀑布流相册、短视频封面墙;
  • 非首屏图片:页面底部、折叠面板、弹窗内的图片,用户初始浏览不到的资源;
  • 大体积图片:高清banner、详情图、实拍图,单张体积超过100KB的资源。

禁忌场景:首屏核心图片(Logo、首屏banner、导航图标)严禁懒加载,否则会恶化首屏渲染速度。

三、实战:图片懒加载3种实现方案(从基础到进阶)

针对不同项目兼容性、性能要求,整理3种最常用的图片懒加载方案,覆盖原生JS、浏览器原生API、HTML原生属性,按需选择即可。

方案1:原生JS + 滚动监听 + getBoundingClientRect(兼容老浏览器)

这是最基础、兼容性最强的方案,通过监听 scroll 滚动事件,结合 getBoundingClientRect() 获取图片元素位置,判断是否进入视口,支持 IE 等老旧浏览器,适合老项目改造。

核心要点:搭配节流函数优化scroll 高频触发问题,减少性能损耗。

完整代码实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>图片懒加载-滚动监听版</title>
  <style>
    img {
      width: 100%;
      max-width: 800px;
      /* 固定宽高比,防止布局偏移(CLS) */
      aspect-ratio: 16/9;
      object-fit: cover;
      background: #f5f7fa;
      margin: 20px auto;
      display: block;
    }
  </style>
</head>
<body>
  <!-- 懒加载图片:data-src存真实地址,src为占位图 -->
  <img class="lazy-img" data-src="https://picsum.photos/800/450?1" src="loading.svg" alt="懒加载图片1">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?2" src="loading.svg" alt="懒加载图片2">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?3" src="loading.svg" alt="懒加载图片3">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?4" src="loading.svg" alt="懒加载图片4">

  <script>
    // 1. 获取所有懒加载图片
    const lazyImages = document.querySelectorAll('.lazy-img');
    // 2. 节流函数:控制scroll触发频率,避免频繁执行
    const throttle = (fn, delay = 200) => {
      let timer = null;
      return (...args) => {
        if (!timer) {
          timer = setTimeout(() => {
            fn.apply(this, args);
            timer = null;
          }, delay);
        }
      };
    };

    // 3. 核心:判断图片是否进入可视区域
    const lazyLoad = () => {
      lazyImages.forEach((img) => {
        // 获取图片相对于视口的位置信息
        const rect = img.getBoundingClientRect();
        // 判定条件:图片顶部 ≤ 视口高度 且 图片底部 ≥ 0(进入可视区域)
        const isInView = rect.top <= window.innerHeight && rect.bottom >= 0;
        
        if (isInView) {
          // 替换真实图片地址
          img.src = img.dataset.src;
          // 加载失败兜底图
          img.onerror = () => { img.src = 'error.svg'; };
          // 移除懒加载类,避免重复处理
          img.classList.remove('lazy-img');
        }
      });
    };

    // 初始化:加载首屏图片
    lazyLoad();
    // 监听滚动事件(节流优化)
    window.addEventListener('scroll', throttle(lazyLoad));
    // 监听窗口缩放,适配不同屏幕
    window.addEventListener('resize', throttle(lazyLoad));
  </script>
</body>
</html>
方案优缺点
  • ✅ 优点:兼容性拉满,逻辑简单,无需依赖第三方库,易调试;
  • ❌ 缺点:scroll 事件触发频率高,即使节流仍有一定性能损耗,需手动处理边界场景。

方案2:Intersection Observer API(现代浏览器首选)

Intersection Observer 是浏览器原生提供的异步交叉观察器,专门用于监听元素与视口(或父容器)的交叉状态,无需手动监听滚动事件,由浏览器底层优化,性能远超滚动监听方案,是目前主流的图片懒加载实现方式。

核心优势:异步执行、无性能损耗、支持提前加载、配置灵活。

完整代码实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>图片懒加载-Intersection Observer版</title>
  <style>
    img {
      width: 100%;
      max-width: 800px;
      aspect-ratio: 16/9;
      object-fit: cover;
      background: #f5f7fa;
      margin: 20px auto;
      display: block;
    }
  </style>
</head>
<body>
  <img class="lazy-img" data-src="https://picsum.photos/800/450?1" src="loading.svg" alt="懒加载图片1">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?2" src="loading.svg" alt="懒加载图片2">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?3" src="loading.svg" alt="懒加载图片3">
  <img class="lazy-img" data-src="https://picsum.photos/800/450?4" src="loading.svg" alt="懒加载图片4">

  <script>
    // 1. 创建观察器实例
    const imgObserver = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        // 判断图片是否进入可视区域
        if (entry.isIntersecting) {
          const img = entry.target;
          // 加载真实图片
          img.src = img.dataset.src;
          // 错误兜底
          img.onerror = () => { img.src = 'error.svg'; };
          // 取消观察,避免重复触发
          observer.unobserve(img);
        }
      });
    }, {
      // 配置项:提前10%视口高度触发,提升用户体验
      rootMargin: '10% 0px',
      // 触发阈值:0表示元素刚进入视口就加载
      threshold: 0
    });

    // 2. 遍历所有图片,开启观察
    document.querySelectorAll('.lazy-img').forEach(img => {
      imgObserver.observe(img);
    });
  </script>
</body>
</html>
关键配置项解析
  • root:监听的根容器,默认是浏览器视口,可指定父容器实现局部滚动懒加载;
  • rootMargin:扩展触发边界,正值提前加载,负值延迟加载(例:10% 0px 表示图片距离视口底部10%高度时就加载);
  • threshold:元素可见比例,取值0-1,0为刚可见就触发,1为完全可见才触发。
方案优缺点
  • ✅ 优点:性能极致、代码简洁、支持预加载、无需处理节流/缩放;
  • ❌ 缺点:不兼容 IE 浏览器,可引入 polyfill 做兼容处理。

方案3:HTML原生loading属性(极简零代码)

现代浏览器(Chrome 77+、Firefox 75+、Edge 79+)支持原生 loading="lazy" 属性,无需编写任何 JS 代码,直接在 img 标签添加该属性,浏览器自动实现图片懒加载,是最简单、最轻量的方案。

适用场景:新项目、无需兼容老旧浏览器、追求极简开发的场景。

代码实现
<!-- 原生懒加载:仅需添加 loading="lazy" -->
<img 
  src="https://picsum.photos/800/450?1" 
  loading="lazy" 
  alt="原生懒加载图片"
  width="800"
  height="450"
>
<img 
  src="https://picsum.photos/800/450?2" 
  loading="lazy" 
  alt="原生懒加载图片"
  width="800"
  height="450"
>
注意事项
  • 必须设置图片 width 和 height,否则浏览器无法判断布局,可能失效;
  • 首屏图片不建议使用,浏览器可能会强制加载首屏内的图片;
  • 兼容性有限,老旧浏览器会忽略该属性,直接加载图片(优雅降级)。

四、图片懒加载避坑指南(实战必看)

实操图片懒加载时,这几个坑极易忽略,直接影响用户体验和性能指标:

1. 严防布局偏移(CLS)

这是最常见问题:图片未加载时无固定占位高度,加载后撑开页面导致布局抖动,CLS指标超标。

解决方案:通过 CSS aspect-ratio 固定宽高比,或提前设置 width/height,预留图片空间。

2. 占位图优化

  • 占位图体积尽量小(建议<2KB),推荐使用 SVG 占位图、纯色背景或 Base64 缩略图;
  • 避免使用高清图做占位,失去懒加载意义。

3. 图片加载失败兜底

网络异常、图片地址失效会导致图片加载失败,需通过 onerror 事件替换兜底图,提升体验。

4. 及时销毁监听/观察器

JS 实现的懒加载,图片加载完成后务必移除滚动监听、取消 Intersection Observer 观察,防止内存泄漏。

5. 兼容禁用JS场景

部分用户禁用浏览器 JS,会导致图片无法加载,通过 <noscript> 标签兜底。

<img class="lazy-img" data-src="real.jpg" src="loading.svg" alt="图片">
<!-- 禁用JS时直接加载真实图片 -->
<noscript>
  <img src="real.jpg" alt="图片" width="800" height="450">
</noscript>

五、优化效果验证工具

图片懒加载落地后,通过以下工具验证优化效果:

  1. Chrome 开发者工具:打开 Network 面板,筛选 Img,滚动页面观察图片请求是否延迟触发;
  2. Lighthouse:生成性能报告,查看 LCP(最大内容绘制)、CLS(累积布局偏移)指标是否优化;
  3. Performance 面板:查看首屏加载耗时、页面渲染帧率是否提升。

六、工程化进阶:懒加载指令封装+主流插件实战

实际项目开发中,重复手写懒加载逻辑效率太低,更推荐封装复用指令/Hooks或直接使用成熟插件,适配Vue、React等框架工程化场景,下面附上可直接复用的封装代码和插件用法。

6.1 Vue3 图片懒加载自定义指令(全局封装)

基于 Intersection Observer 封装全局懒加载指令,一键复用,无需重复写监听逻辑,适配Vue3项目。

步骤1:创建指令文件(directives/lazyLoad.js)
// 全局图片懒加载指令
const lazyLoad = {
  mounted(el, binding) {
    // 初始化占位图
    el.src = 'loading.svg';
    // 创建观察器
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;
      if (entry.isIntersecting) {
        // 加载真实图片
        el.src = binding.value;
        // 加载失败兜底
        el.onerror = () => { el.src = 'error.svg'; };
        // 停止观察
        observer.unobserve(el);
      }
    }, { rootMargin: '10% 0px' });
    // 绑定观察对象
    observer.observe(el);
    // 存储观察器,用于卸载
    el._observer = observer;
  },
  // 指令卸载时销毁观察器
  unmounted(el) {
    el._observer?.unobserve(el);
  }
};

export default lazyLoad;
步骤2:全局注册指令(main.js)
import { createApp } from 'vue';
import App from './App.vue';
import lazyLoad from './directives/lazyLoad';

const app = createApp(App);
// 注册全局指令 v-lazy
app.directive('lazy', lazyLoad);
app.mount('#app');
步骤3:页面使用
<!-- 直接使用 v-lazy 指令,传入真实图片地址 -->
<img v-lazy="https:/picsum.photos800/450?1" alt="指令懒加载" /

6.2 React 图片懒加载 Hooks 封装

封装自定义Hook,实现React函数组件复用,适配React项目。

import { useEffect, useRef } from 'react';

// 自定义懒加载Hook
function useLazyImg() {
  const imgRef = useRef(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      const [entry] = entries;
      if (entry.isIntersecting) {
        const img = entry.target;
        img.src = img.dataset.src;
        img.onerror = () => { img.src = 'error.svg'; };
        observer.unobserve(img);
      }
    }, { rootMargin: '10% 0px' });

    if (imgRef.current) observer.observe(imgRef.current);

    return () => {
      if (imgRef.current) observer.unobserve(imgRef.current);
    };
  }, []);

  return imgRef;
}

// 组件使用
export default function LazyImage({ src, alt }) {
  const imgRef = useLazyImg();
  return (
    <img
      ref={src}
      src="loading.svg"
      alt={alt}
      style={{ width: '100%', aspectRatio: '16/9' }}
    />
  );
}

6.3 主流懒加载插件推荐(开箱即用)

不想手动封装,可直接使用社区成熟插件,配置简单、功能完善。

Vue2/Vue3:vue-lazyload

Vue生态最常用的图片懒加载插件,支持占位图、加载失败、节流等功能。

# 安装
npm install vue-lazyload --save
// 注册(main.js)
import Vue from 'vue';
import VueLazyload from 'vue-lazyload';

Vue.use(VueLazyload, {
  preLoad: 1.3, // 提前加载比例
  error: 'error.svg', // 失败图
  loading: 'loading.svg', // 占位图
  attempt: 1 // 加载次数
});

// 页面使用
React:react-lazy-load-image-component

React专用懒加载组件,支持淡入动画、占位、响应式,适配SSR场景。

# 安装
npm install react-lazy-load-image-component --save
import { LazyLoadImage } from 'react-lazy-load-image-component';
import 'react-lazy-load-image-component/src/effects/blur.css';

// 使用
function ImageList() {
  return (
    <LazyLoadImage
      src="https://picsum.photos/800/450"
      alt="插件懒加载"
      effect="blur" // 淡入模糊效果
      placeholderSrc="loading.svg" // 占位图
      width="100%"
    />
  );
}

七、方案选型总结

  • 原生开发/老项目:选滚动监听 + getBoundingClientRect 方案;
  • 现代浏览器/追求性能:选Intersection Observer API 方案(首选);
  • 极简开发/零代码:选原生 loading="lazy"  属性;
  • Vue/React工程化:优先用自定义指令/Hooks,快速复用;
  • 快速落地:直接用 vue-lazyload/react-lazy-load-image-component 插件。

每个 React 开发者都需要的 10 个浏览器 API Hooks

学习如何在 React 中使用 Geolocation、Clipboard、Fullscreen、Media Queries 等浏览器 API,借助 ReactUse 提供的简洁、可复用的 Hooks。

原文发布于 reactuse.com

现代浏览器提供了强大的 API,包括地理定位、剪贴板访问、全屏模式、网络状态等等。在 React 中直接使用它们比应有的难度更大。你需要防范服务端渲染、添加和移除事件监听器、处理权限,以及在卸载时清理。将这些工作乘以你的应用涉及的每个浏览器 API,你就有了大量重复且容易出错的代码。

ReactUse 通过一个包含 100 多个 Hooks 的库来解决这个问题,将浏览器 API 封装为简洁的、SSR 安全的、TypeScript 友好的接口。只需安装一次,按需导入:

npm i @reactuses/core

1. useMediaQuery -- 响应式设计

在 JavaScript 中响应 CSS 媒体查询。该 Hook 返回一个布尔值,在视口变化时实时更新。

import { useMediaQuery } from "@reactuses/core";

function App() {
  const isMobile = useMediaQuery("(max-width: 768px)");
  return <div>{isMobile ? <MobileNav /> : <DesktopNav />}</div>;
}

2. useClipboard -- 复制到剪贴板

使用现代 Clipboard API 读写系统剪贴板。该 Hook 处理权限、HTTPS 要求和焦点状态边界情况。

import { useClipboard } from "@reactuses/core";

function CopyButton({ text }: { text: string }) {
  const [clipboardText, copy] = useClipboard();
  return (
    <button onClick={() => copy(text)}>
      {clipboardText === text ? "Copied!" : "Copy"}
    </button>
  );
}

3. useGeolocation -- 用户位置

追踪用户的地理坐标,在卸载时自动清理 watchPosition 监听器。

import { useGeolocation } from "@reactuses/core";

function LocationDisplay() {
  const { coordinates, error, isSupported } = useGeolocation();
  if (!isSupported) return <p>不支持地理定位。</p>;
  if (error) return <p>错误: {error.message}</p>;
  return <p>纬度: {coordinates.latitude}, 经度: {coordinates.longitude}</p>;
}

4. useFullscreen -- 全屏模式

对任意元素切换全屏。该 Hook 封装了 Fullscreen API,返回当前状态和控制函数。

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

function VideoPlayer() {
  const ref = useRef<HTMLDivElement>(null);
  const [isFullscreen, { toggleFullscreen }] = useFullscreen(ref);
  return (
    <div ref={ref}>
      <video src="/demo.mp4" />
      <button onClick={toggleFullscreen}>
        {isFullscreen ? "退出" : "全屏"}
      </button>
    </div>
  );
}

5. useNetwork -- 在线/离线状态

监控用户的网络连接。该 Hook 追踪在线/离线状态,在可用时还提供连接详情。

import { useNetwork } from "@reactuses/core";

function NetworkBanner() {
  const { online, effectiveType } = useNetwork();
  if (!online) return <div className="banner">您已离线</div>;
  return <div>连接类型: {effectiveType}</div>;
}

6. useIdle -- 空闲检测

检测用户何时停止与页面交互。

import { useIdle } from "@reactuses/core";

function IdleWarning() {
  const isIdle = useIdle(300_000); // 5 分钟
  return isIdle ? <div>你还在吗?</div> : null;
}

7. useDarkMode -- 深色模式切换

管理深色模式,包含系统偏好检测、localStorage 持久化和根元素自动类名切换。

import { useDarkMode } from "@reactuses/core";

function ThemeToggle() {
  const [isDark, toggle] = useDarkMode({
    classNameDark: "dark",
    classNameLight: "light",
  });
  return (
    <button onClick={toggle}>
      {isDark ? "切换到浅色" : "切换到深色"}
    </button>
  );
}

8. usePermission -- 权限状态

查询浏览器权限的状态并实时响应变化。

import { usePermission } from "@reactuses/core";

function CameraAccess() {
  const status = usePermission("camera");
  if (status === "denied") return <p>摄像头访问被拒绝。</p>;
  if (status === "prompt") return <p>我们需要摄像头权限。</p>;
  return <p>摄像头访问已授权。</p>;
}

9. useLocalStorage -- 持久化状态

useState 的替代方案,持久化到 localStorage。处理序列化、SSR 安全性、跨标签页同步和错误恢复。

import { useLocalStorage } from "@reactuses/core";

function Settings() {
  const [lang, setLang] = useLocalStorage("language", "en");
  return (
    <select value={lang ?? "en"} onChange={(e) => setLang(e.target.value)}>
      <option value="en">English</option>
      <option value="es">Spanish</option>
      <option value="fr">French</option>
    </select>
  );
}

10. useEventListener -- 事件处理

将事件监听器附加到任何目标,自动清理,并提供 TypeScript 安全的事件类型。

import { useEventListener } from "@reactuses/core";

function KeyLogger() {
  useEventListener("keydown", (event) => {
    console.log("按键:", event.key);
  });
  return <p>按任意键...</p>;
}

手动实现 vs. ReactUse

关注点 手动实现 ReactUse Hook
SSR 安全检查 到处添加 typeof window !== "undefined" 内置
事件监听器清理 useEffect + removeEventListener 自动
TypeScript 事件类型 手动泛型约束 完全类型化
localStorage 序列化 JSON.parse/stringify + 错误处理 自动
跨标签页同步 手动 storage 事件监听 内置

对于单个 Hook 来说节省量不大。但在整个应用中使用五个或更多浏览器 API 时,ReactUse 消除了数百行防御性代码。


ReactUse 提供了 100 多个 React Hooks。查看全部 →

🚀 深入浅出 Event Loop:带你彻底搞懂 JS 执行机制

你好呀,掘金的各位小伙伴们!👋

今天我们要来聊一个老生常谈,但每次提起都能让人“掉几根头发”的话题 —— Event Loop(事件循环)

你是不是也曾在面试中被面试官那迷离的眼神注视着,问道:

“同学,你能告诉我 setTimeoutPromiseasync/await 到底谁先执行吗?为什么?” 🤯

或者在写代码时,发现打印出来的日志顺序和你想的完全不一样,怀疑人生?🤔

别担心!今天这篇文章,我们就把这些“玄学”问题一次性讲清楚!我们将不再只是死记硬背“宏任务”、“微任务”的定义,而是结合浏览器底层原理,用最轻松愉快的语气,带你啃下这块最硬的骨头!🍖

准备好了吗?我们要发车啦!🚗💨


🧐 第一部分:浏览器的“打工”日常 —— 进程与线程

在深入 Event Loop 之前,我们得先了解一下 JS 代码运行的“环境” —— 浏览器。

大家常说“JS 是单线程的”,但这其实是说 JS 的主线程 是单线程的。现代浏览器(比如 Chrome)其实是一个多进程的架构。你可以把浏览器想象成一个大型工厂 🏭。

1.1 浏览器的核心“部门”(进程)

这个工厂里有几个核心部门(进程),它们各司其职:

  • Browser 进程 🧠:工厂的“厂长”。负责界面显示、用户交互、子进程管理等。
  • GPU 进程 🎨:负责 3D 绘制等图形工作。
  • Network 进程 📡:负责网络资源加载。
  • Plugin 进程 🧩:负责插件运行。
  • Renderer 进程(渲染进程) 🏗️:重点来了! 这是我们要关注的主角。每个 Tab 页通常都有自己独立的渲染进程。

1.2 渲染进程的“繁忙”生活

渲染进程的主要职责是把 HTML、CSS、JS 变成用户看得到的网页。这个部门里有一个超级忙碌的员工,名叫 “渲染主线程” (Main Thread)

这个主线程有多忙呢?来看看它的 To-Do List:

  1. 解析 HTML 📄:生成 DOM 树。
  2. 计算样式 💅:构建 CSSOM 树。
  3. 布局 (Layout) 📐:计算元素的位置和大小,生成 Layout Tree。
  4. 分层 (Layer) 🍰:处理图层。
  5. 绘制 (Paint) 🖌️:生成绘制指令。
  6. 执行 JavaScript ⚡:处理业务逻辑、交互等。

划重点:⚠️ 渲染和 JS 执行是互斥的! 也就是说,主线程在执行 JS 的时候,就不能进行渲染;在渲染的时候,就不能执行 JS。它就像一个单核 CPU,同一时间只能做一件事。


🤔 第二部分:为什么 JS 必须是单线程?

你可能会问:“既然主线程这么忙,为什么不多招几个人(多线程)一起干呢?”

想象一下,如果 JS 是多线程的:

  • 线程 A 想把某个 DOM 节点删掉 ❌。
  • 线程 B 想给同一个 DOM 节点添加子元素 ➕。
  • 这时候浏览器该听谁的?🤷‍♂️

为了避免这种复杂的同步问题,JS 从诞生之初就设计为单线程。简单、高效,但副作用就是——容易堵车。🚗🚕🚙


🔄 第三部分:Event Loop —— 永不休止的循环

既然是单线程,如果遇到耗时的任务怎么办?比如:

  • 网络请求(要等几秒钟)⏳
  • 定时器(要等几秒钟)⏲️
  • 用户点击(不知道什么时候点)🖱️

如果主线程傻傻地等着这些任务完成,那页面早就卡死了!😱

为了解决这个问题,浏览器引入了 消息队列 (Message Queue)事件循环 (Event Loop) 机制。

3.1 浏览器的“排队”策略

我们可以把主线程看作是一个永不停歇的检票员 👮‍♂️。

  1. 同步任务:就像是买了 VIP 票的乘客,直接在主线程上执行,立即处理。
  2. 异步任务
    • 主线程发起异步任务(比如 setTimeoutfetch)。
    • 相应的其他线程(如定时器线程、网络线程)去处理这些耗时操作。
    • 一旦处理完成(比如时间到了、数据回来了),这些线程会把回调函数包装成一个任务,扔进消息队列里排队。

3.2 循环机制 (The Loop)

主线程(检票员)的工作逻辑是这样的:

// 伪代码模拟 Event Loop
for (;;) {
    // 1. 看看消息队列里有没有任务
    Task task = message_queue.take();
    
    if (task) {
        // 2. 有任务,拿出来执行
        Process(task);
    } else {
        // 3. 没任务,休息一下,等待新任务(休眠)
        Sleep();
    }
}

这就是 Event Loop!主线程不断地从消息队列中取出任务执行,执行完一个,再去取下一个。


⚖️ 第四部分:宏任务 vs 微任务 —— 优先级的博弈

但是!事情并没有那么简单。队列不只有一个! 为了更精细地控制任务的执行时机,浏览器把异步任务分成了两类:

4.1 🐢 宏任务 (Macro Task)

通常我们说的“任务”就是指宏任务。消息队列里的每一个任务本质上都是宏任务。

  • 来源script (整体代码)、setTimeoutsetInterval、I/O、UI 交互事件、postMessage 等。
  • 特点:每次 Event Loop 循环只执行一个宏任务。

4.2 🐇 微任务 (Micro Task)

微任务是 VIP 中的 VIP,它不需要去普通的消息队列排队,而是有一个专门的微任务队列

  • 来源Promise.then/catch/finallyasync/awaitMutationObserverqueueMicrotask
  • 特点在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务! 🧹

4.3 🔄 完整的 Event Loop 流程

  1. 执行一个宏任务(最开始是 script 整体代码)。
  2. 遇到同步代码:直接执行。
  3. 遇到微任务:放入微任务队列。
  4. 遇到宏任务:交给其他模块处理,处理完放入宏任务队列。
  5. 当前宏任务执行完毕
  6. 检查微任务队列
    • 如果有微任务,依次执行所有微任务,直到队列为空。(如果在执行微任务的过程中又产生了新的微任务,也会在这一轮里被执行掉!无限套娃警告 ⚠️)
  7. 尝试进行页面渲染 (UI Rendering) 🎨。(并不是每次循环都会渲染,通常 60Hz 频率下每 16.6ms 渲染一次)。
  8. 开始下一轮 Event Loop:从宏任务队列取下一个任务执行。

⚔️ 第五部分:硬核实战 —— 代码执行全解析

光说不练假把式。我们拿一段包含各种情况的复杂代码来“解剖”一下!🔪

这是我们的测试代码:

// 1.html 源码解析
<script>
    // ------------------- 代码开始 -------------------
    console.log('同步代码 1'); // line 10

    setTimeout(() => { // line 12
        console.log('setTimeout 1');
        Promise.resolve().then(() => {
            console.log('setTimeout 1 内部微任务');
        });
    }, 0);

    const promise1 = new Promise((resolve) => { // line 19
        console.log('Promise 构造函数');
        resolve();
        console.log('Promise 构造函数内 resolve 后');
    });

    promise1.then(() => { // line 25
        console.log('Promise.then 1');
        setTimeout(() => {
            console.log('Promise.then 1 内部 setTimeout');
        }, 0);
    });

    async function asyncFn() { // line 32
        console.log('async 函数同步部分');
        // await 后面的所有代码 作为 promise.then 的回调函数里面的代码
        await Promise.resolve(); // 异步变同步的语法糖,本质还是异步的
        console.log('await 后微任务');
    }

    asyncFn(); // line 39

    console.log('同步代码 2'); // line 41

    // html5 标准 微任务队列
    queueMicrotask(() => { // line 43
        console.log('queueMicrotask 微任务');
    });

    // 额外增加 DOM 监听类微任务(前端特有)
    const observer = new MutationObserver(() => { // line 48
        console.log('MutationObserver 微任务');
    });
    const div = document.createElement('div');
    observer.observe(div, { attributes: true });
    div.setAttribute('data-test', '1'); // 触发 MutationObserver
    // ------------------- 代码结束 -------------------
</script>

🕵️‍♂️ 详细执行步骤解析

我们将整个执行过程分为三个阶段:

  1. 第一轮宏任务(Script 整体代码)执行
  2. 清空微任务队列
  3. 第二轮宏任务(如果有)

🎬 第一阶段:执行主线程同步代码(Script 宏任务)

  1. Line 10: console.log('同步代码 1')
    • 👉 输出: '同步代码 1'
  2. Line 12: setTimeout(..., 0)
    • 这是一个宏任务。浏览器将回调函数交给定时器线程。因为是 0ms,它会尽快被放入宏任务队列
    • 🏗️ 宏任务队列: [setTimeout1_callback]
  3. Line 19: new Promise(...)
    • 注意Promise 的构造函数是同步执行的!
    • console.log('Promise 构造函数') 👉 输出: 'Promise 构造函数'
    • resolve():Promise 状态变为 Resolved。
    • console.log('Promise 构造函数内 resolve 后') 👉 输出: 'Promise 构造函数内 resolve 后'
  4. Line 25: promise1.then(...)
    • 这是一个微任务。因为 promise1 已经 resolve 了,回调函数被放入微任务队列
    • 🧬 微任务队列: [promise1_then_callback]
  5. Line 39: 执行 asyncFn()
    • 进入函数体。
    • Line 33: console.log('async 函数同步部分') 👉 输出: 'async 函数同步部分'
    • Line 35: await Promise.resolve()
      • await 这一行右边的代码是同步执行的(这里是 Promise.resolve())。
      • 关键点await 就像一个分界线。它下面的代码(console.log('await 后微任务'))会被相当于放入一个 Promise.then 中。
      • 所以,await 后面的逻辑进入微任务队列
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback]
  6. Line 41: console.log('同步代码 2')
    • 👉 输出: '同步代码 2'
  7. Line 43: queueMicrotask(...)
    • 直接添加一个微任务。
    • 🧬 微任务队列: [promise1_then_callback, async_await_callback, queueMicrotask_callback]
  8. Line 48-53: MutationObserver
    • div.setAttribute 修改了 DOM,触发了观察者。这是一个微任务。
    • 🧬 微任务队列: [..., queueMicrotask_callback, mutation_observer_callback]

🏁 第一阶段小结控制台输出

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2

当前队列状态

  • 微任务队列[promise1.then, async_await, queueMicrotask, MutationObserver]
  • 宏任务队列[setTimeout1]

🧹 第二阶段:清空微任务队列

Script 宏任务执行完了,现在主线程空了。Event Loop 检查微任务队列,发现一大堆任务,开始依次执行。

  1. 执行 promise1.then 回调
    • console.log('Promise.then 1') 👉 输出: 'Promise.then 1'
    • setTimeout(..., 0):产生一个新的宏任务!放入宏任务队列。
    • 🏗️ 宏任务队列: [setTimeout1, setTimeout_inside_then]
  2. 执行 asyncFnawait 后的代码
    • console.log('await 后微任务') 👉 输出: 'await 后微任务'
  3. 执行 queueMicrotask 回调
    • console.log('queueMicrotask 微任务') 👉 输出: 'queueMicrotask 微任务'
  4. 执行 MutationObserver 回调
    • console.log('MutationObserver 微任务') 👉 输出: 'MutationObserver 微任务'

🏁 第二阶段小结: 微任务队列清空了!🎉 控制台新增输出

Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务

🎬 第三阶段:执行下一个宏任务

微任务清空后,浏览器可能会进行渲染(Render)。然后 Event Loop 再次转动,去宏任务队列取任务。

  1. 取出 setTimeout1 的回调

    • console.log('setTimeout 1') 👉 输出: 'setTimeout 1'
    • Promise.resolve().then(...)注意! 这里又产生了一个微任务!
    • 这个微任务会立刻被加入微任务队列。
    • 🧬 微任务队列: [setTimeout1_microtask]
    • 当前宏任务执行完毕
  2. 再次检查微任务队列(你以为完了?并没有!):

    • 发现刚才新产生的微任务 [setTimeout1_microtask]
    • 立即执行!
    • console.log('setTimeout 1 内部微任务') 👉 输出: 'setTimeout 1 内部微任务'
  3. 取出 setTimeout_inside_then 的回调(来自 promise1.then 内部):

    • console.log('Promise.then 1 内部 setTimeout') 👉 输出: 'Promise.then 1 内部 setTimeout'

✅ 最终输出结果

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

📝 第六部分:总结与避坑指南

通过上面的分析,我们可以总结出几条黄金法则:

  1. JS 主线程是单线程的,依靠 Event Loop 搞定异步。
  2. 同步代码优先:所有同步代码都在第一个宏任务(Script)中执行。
  3. 微任务插队:宏任务执行完,必须清空微任务队列,才能去执行下一个宏任务。
  4. Promise 构造函数是同步的then 才是微任务。
  5. await 是分水岭await 右边是同步,下面是微任务。

💡 为什么懂这个很重要?

  • 性能优化:如果你在微任务里写了死循环或者巨量计算,会导致页面卡死,因为宏任务(如渲染、点击响应)永远没机会执行!这叫“微任务阻塞”。
  • Bug 排查:理解执行顺序,才能知道为什么你的数据没更新,或者为什么 DOM 还没渲染出来代码就报错了。

希望这篇文章能帮你彻底打通 Event Loop 的任督二脉!下次面试,请自信地告诉面试官:“我不仅知道结果,我还知道浏览器底层是怎么跑的!” 😎


本文代码示例基于 Chrome 浏览器环境,不同浏览器或 Node.js 版本可能存在细微差异,但标准模型大同小异。

一文详解JS中的执行顺序——事件循环(宏任务、微任务)

一文详解JS中的执行顺序——事件循环(宏任务、微任务)

为什么 JavaScript 是单线程的?

JavaScript 诞生的初衷是为了处理网页上的简单交互,比如表单验证。试想一下,如果 JavaScript 是多线程的:

  • 线程 A 想修改某个 DOM 节点的内容
  • 线程 B 想删除同一个 DOM 节点

这就会导致复杂的同步问题(锁机制),对于轻量级的网页脚本来说太重了。因此,JavaScript 从诞生起就是单线程的,这意味着它在同一时间只能做一件事。

但“单线程”并不意味着它慢。JavaScript 巧妙地利用了异步非阻塞机制,配合 Event Loop (事件循环),让它能够高效地处理大量并发任务(如网络请求、定时器、DOM 事件)。

核心概念解析

为了理解 Event Loop,我们需要先搞清楚几个“角色”:

同步任务 (Synchronous)

那些立即执行不等待其他操作完成、并且按顺序在主线程上依次执行的任务就是同步任务。

你可以直接把这段代码复制到浏览器的控制台(F12 -> Console)运行:

console.log('1. 任务开始');

// 【同步任务】:一个极其耗时的循环
// 假设我们要计算 10 亿次加法
let sum = 0;
const limit = 1000000000; // 10 亿

console.log('2. 开始执行耗时同步任务 (请观察页面是否卡住)...');

for (let i = 0; i < limit; i++) {
  sum += i;
  // 注意:在这个循环结束前,JS 引擎绝对不会去处理任何其他事情
  // 你的鼠标点击、页面滚动、定时器回调、网络请求完成等,全部被阻塞!
}

console.log('3. 耗时任务结束,结果:', sum);

当今浏览器的性能虽说不至于卡死,但是在进行计算的这一秒内你尝试滚动页面,发现页面似乎无响应了,这就是JS的主进程被阻塞,无法执行其他任务(页面滚动)。

异步任务 (Asynchronous)

异步任务就是“现在不执行,将来某个时刻再执行”的任务。  它们不会阻塞主线程,而是将回调函数注册好,交给浏览器(或 Node.js)的 API 去处理,等处理完了,再把回调函数放入队列,等待 Event Loop 在合适的时机执行。

宏任务 (MacroTask)

  • 代表一个个离散的、独立的任务。
  • 浏览器为了能够使 JS 内部 task 与 DOM 任务能够有序的执行,会在一个 task 执行结束后,在下一个 task 执行开始前,对页面进行重新渲染。
  • 常见宏任务
    • 整体代码 script (可以理解为第一个宏任务)
    • setTimeout / setInterval
    • UI 渲染 / I/O

微任务 (MicroTask)

  • 优先级高于宏任务(除了当前的 script)。
  • 在当前宏任务执行结束后,下一次渲染之前,会立即清空所有的微任务。
  • 常见微任务
    • Promise.then / catch / finally
    • async/await (本质是 Promise)
    • MutationObserver (监听 DOM 变化)
    • queueMicrotask
image.png ---

Event Loop 执行流程

这就是 JavaScript 永不停歇的“心脏”跳动机制:

  1. 执行栈 (Call Stack) 选择最先进入队列的宏任务(通常是整体 script 代码),执行其同步代码。
  2. 执行过程中,遇到微任务,将其放入微任务队列
  3. 执行过程中,遇到宏任务(如 setTimeout),将其回调放入宏任务队列
  4. 当前宏任务执行完毕(Call Stack 清空)。
  5. 关键步骤:检查微任务队列。如果有微任务,依次执行所有微任务,直到队列清空。
    • 注意:如果在执行微任务的过程中又产生了新的微任务,会继续添加到队列末尾并在本次循环中一并执行!这可能导致“死循环”阻塞页面渲染。
  6. 渲染页面(如果有必要)。
  7. 检查宏任务队列,取出下一个宏任务,回到步骤 1。

口诀:

同步先行 -> 清空微任务 -> 渲染 -> 下一个宏任务

代码案例

让我们通过一段复杂的代码来彻底捋清楚执行顺序。

// 1. 同步代码
console.log('同步代码 1');

// 2. 宏任务 (setTimeout)
setTimeout(() => {
  console.log('setTimeout 1');
  Promise.resolve().then(() => {
    console.log('setTimeout 1 内部微任务');
  });
}, 0);

// 3. Promise 构造函数 (同步)
const promise1 = new Promise((resolve) => {
  console.log('Promise 构造函数');
  resolve();
  console.log('Promise 构造函数内 resolve 后');
});

// 4. 微任务 (Promise.then)
promise1.then(() => {
  console.log('Promise.then 1');
  setTimeout(() => {
    console.log('Promise.then 1 内部 setTimeout');
  }, 0);
});

// 5. Async/Await (同步+微任务)
async function asyncFn() {
  console.log('async 函数同步部分');
  // await 相当于 Promise.resolve().then(...)
  // await 这一行及之后的代码会被放入微任务队列
  await Promise.resolve(); 
  console.log('await 后微任务');
}

asyncFn();

// 6. 同步代码
console.log('同步代码 2');

// 7. 微任务 (queueMicrotask)
queueMicrotask(() => {
  console.log('queueMicrotask 微任务');
});

// 8. 微任务 (MutationObserver)
const observer = new MutationObserver(() => {
  console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); // 触发

执行步骤详解

第一轮:执行 Script 宏任务(同步代码)

  1. 执行 console.log('同步代码 1')

    • 控制台输出同步代码 1
  2. 执行 setTimeout(...)

    • 宏任务队列[SetTimeout1]
  3. 执行 new Promise(...)

    • 控制台输出Promise 构造函数
    • 控制台输出Promise 构造函数内 resolve 后
  4. 执行 promise1.then(...)

    • 微任务队列[Then1]
  5. 执行 asyncFn()

    • 控制台输出async 函数同步部分
    • 微任务队列[Then1, Await]
  6. 执行 console.log('同步代码 2')

    • 控制台输出同步代码 2
  7. 执行 queueMicrotask(...)

    • 微任务队列[Then1, Await, Queue]
  8. 执行 MutationObserver

    • 微任务队列[Then1, Await, Queue, Observer]

第二轮:清空微任务队列

  1. 取出 Then1 执行

    • 控制台输出Promise.then 1
    • 宏任务队列[SetTimeout1, SetTimeout2]
  2. 取出 Await 执行

    • 控制台输出await 后微任务
  3. 取出 Queue 执行

    • 控制台输出queueMicrotask 微任务
  4. 取出 Observer 执行

    • 控制台输出MutationObserver 微任务

第三轮:执行下一个宏任务

  1. 取出 SetTimeout1 执行

    • 控制台输出setTimeout 1
    • 微任务队列[InnerThen] (宏任务中产生的微任务)
  2. 清空微任务队列(执行 InnerThen

    • 控制台输出setTimeout 1 内部微任务

第四轮:执行再下一个宏任务

  1. 取出 SetTimeout2 执行
    • 控制台输出Promise.then 1 内部 setTimeout

最终输出结果

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout

(注:微任务之间的顺序主要取决于入队顺序,awaitPromise.then 的具体先后可能因浏览器版本/ECMAScript 规范版本略有差异,但在现代浏览器中通常如上所示。MutationObserver 和 queueMicrotask 通常也在微任务队尾)

易错点与避坑指南

Promise 构造函数是同步的

很多人误以为 new Promise 里的代码是异步的。错!只有 .then().catch() 里的回调才是异步微任务。

Await 的本质

await xxx 相当于 Promise.resolve(xxx).then(() => { ...后续代码... })。它把异步代码写得像同步一样,但本质上它是让出了线程,把后续代码扔进了微任务队列。

微任务死循环 (Microtask Loop)

这是一个非常危险的操作! 宏任务执行完一个,会给 UI 渲染的机会。 微任务则是“死磕到底”——只要队列不空,就不停地执行。

如果你在微任务里不断添加新的微任务:

function loop() {
  Promise.resolve().then(loop); // 无限递归微任务
}
loop();

结果:页面完全卡死。因为主线程一直忙着清空微任务,根本没机会去执行 UI 渲染,也没机会去执行下一个宏任务(如点击事件、定时器)。这比 while(true) 更隐蔽,但同样致命。

总结

JavaScript 的 Event Loop 就像一个不知疲倦的调度员:

  1. 先处理手里现有的急事(同步代码)。
  2. 处理完急事,马上看看有没有“小纸条”(微任务),有就一口气全处理完。
  3. 如果“小纸条”处理完了,喘口气,看看能不能画画(UI渲染)。
  4. 最后再去信箱里拿下一封信(宏任务),开始新的轮回。

理解了这个机制,你就能明白为什么 setTimeout(fn, 0) 不一定准时,为什么大量计算要放在 Web Worker 里,以及为什么你的页面有时候会莫名其妙地卡顿了。

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

🚀 JavaScript 内存大揭秘:从“栈堆搬家”到“闭包时空胶囊”

第一章:舞台搭建 —— 内存的三大分区

在代码运行之前,JavaScript 引擎先画好了三块地皮。请看这张图,这是所有故事发生的物理地基

c2e28f0b62e932380333c67696ea1599.jpg

  1. 🟠 代码空间 (Code Space):存放我们的剧本(源代码)。
  2. 🔴 栈空间 (Stack)“临时更衣室”
    • 特点:进出极快,空间小,自动整理。
    • 住谁?函数执行的上下文基本数据类型(数字、布尔值等)。
    • 规则:后进先出(LIFO),函数执行完,里面的东西立马被清空。
  3. 🔵 堆空间 (Heap)“大型仓库”
    • 特点:空间大,存取稍慢,需要保洁员(垃圾回收器 GC)定期打扫。
    • 住谁?对象、数组、函数等复杂的大件物品。

💡 核心隐喻

  • 是演员手里的提词卡(写着简单的数字或地址)。
  • 是后台巨大的道具库(放着复杂的布景和道具)。
  • 演员(变量)手里通常只拿着一张写有道具编号的卡片(引用地址),而不是直接把道具扛在肩上。

第二章:基本类型的“独立副本” —— 深度解析 1.js

让我们先看 1.js 的代码,看看它在栈空间里是怎么“变魔术”的。

📜 代码剧本 (1.js)

function foo() {
    var a = 1;      // 步骤 A
    var b = a;      // 步骤 B
    a = 2;          // 步骤 C
    console.log(a); // 输出 2
    console.log(b); // 输出 1  <-- 为什么 b 没变?
}
foo();

🎬 内存现场直播

步骤 A:var a = 1;

引擎在栈空间开辟了一个格子,贴上标签 a,里面直接放入数字 1

  • 栈状态[ a: 1 ]
  • 堆状态:空(基本类型不住堆)
步骤 B:var b = a; (关键瞬间!)

这是新手最容易误解的地方。

  • 错误理解ba 绑定了,ab 也变。
  • 真相:引擎在栈空间又开辟了一个全新的格子,贴上标签 b。它读取 a 格子里的值(也就是 1),然后复制了一份放到 b 的格子里。
  • 栈状态
    [ a: 1 ]
    [ b: 1 ]  <-- 这是一个独立的副本!
    
  • 此时,a 和 b 毫无关系,只是数值碰巧相同。
步骤 C:a = 2;

引擎找到标签 a 的格子,把里面的 1 擦掉,写上 2

  • 栈状态
    [ a: 2 ]  <-- 只有这里变了
    [ b: 1 ]  <-- b 毫发无损,因为它存的是独立的副本
    
🏁 结局
  • console.log(a) -> 读到 2
  • console.log(b) -> 读到 1

🧠 记忆口诀基本类型是“复印机”。 b = a 是把 a 的内容复印了一份给 b。以后 a 怎么改,跟 b 手里的复印件没关系。


第三章:引用类型的“共享地址” —— 深度解析 2.js

现在难度升级,看看 2.js 中的对象。这时候,堆空间登场了。

📜 代码剧本 (2.js)

function foo() {
    var a = {name: "极客时间"};  // 步骤 A
    var b = a;                    // 步骤 B
    a.name = '极客邦';            // 步骤 C
    console.log(a); 
    console.log(b);               // 输出什么?居然也变了?
}
foo();

🎬 内存现场直播

步骤 A:var a = {name: "极客时间"};
  1. 堆空间行动:引擎发现是个对象(大件物品),不能在栈里直接放。于是它在堆空间申请了一块地盘(假设地址是 1001),把 {name: "极客时间"} 这个对象存进去。
  2. 栈空间行动:在栈里创建变量 a。但是 a 里面不存对象本身,而是存那个对象的门牌号(地址) 1001
  • 栈状态[ a: 1001 (地址) ]
  • 堆状态地址 1001 -> { name: "极客时间" }
步骤 B:var b = a; (最关键的时刻!)
  • 动作:引擎在栈里创建变量 b。它读取 a 里的内容。

  • 注意a 里的内容是 1001(地址)。所以,引擎把 1001 复制给了 b

  • 结果ab 现在都拿着同一张写着 1001 的纸条。它们指向同一个堆内存地址。

  • 栈状态

    [ a: 1001 ]  \
                  +--> 指向堆里的同一个对象
    [ b: 1001 ]  /
    
  • 堆状态地址 1001 -> { name: "极客时间" }

步骤 C:a.name = '极客邦';
  • 动作:引擎通过 a 找到地址 1001,冲进堆空间,把那个对象里的 name 属性改成了 '极客邦'

  • 关键点:它修改的是堆里的实物,而不是栈里的地址。

  • 堆状态更新地址 1001 -> { name: "极客邦" } (实物被改了!)

🏁 结局
  • console.log(a):拿着地址 1001 去堆里看 -> 看到 { name: "极客邦" }
  • console.log(b):拿着地址 1001 去堆里看 -> 还是看到 { name: "极客邦" }

🧠 记忆口诀引用类型是“遥控器”。

  • ab 是两个不同的遥控器(栈里的变量)。
  • 但它们都对着同一台电视机(堆里的对象)。
  • 你用 a 遥控器换了台(修改属性),b 遥控器看到的画面自然也跟着变了。

第四章:闭包的“时空胶囊” —— 结合图片深度拆解

为什么函数执行完了,里面的变量还能被记住?这就是闭包的魔法。我们结合您提供的后三张图来还原这个过程。

场景设定

function foo() {
    var myName = "极客时间";
    var test1 = 1;
    
    function inner() {
        var test2 = 2;
        console.log(myName); // 这里的 myName 从哪来?
    }
    
    return inner; // 把内部函数扔出去
}

var bar = foo(); // foo 执行完了,按理说它的变量该消失了
bar();           // 但这里依然能打印 "极客时间"

第一阶段:函数执行中

foo() 正在运行时:

  1. 调用栈 (Call Stack) 压入了一个 foo 的执行上下文。
  2. 变量环境里记录了:
    • myName: "极客时间"
    • test1: 1
    • inner: 函数定义(包含了一个秘密武器:对外部作用域的引用
  3. 此时一切正常,myName 就安稳地待在 foo 的栈帧里。

第二阶段:返回与引用的建立

这是最神奇的一步!

  1. foo 函数执行结束,按常理,它的执行上下文应该从调用栈弹出,里面的 myName 应该被销毁。
  2. 但是! 因为 inner 函数(现在赋值给了全局变量 bar)在定义时,偷偷通过作用域链抓住了 foo 的变量环境。
  3. 内存迁移
    • 原本应该在栈里随函数结束而消失的 myNametest1,因为被 inner 引用了,引擎被迫将它们从栈空间“转移”或“保留”在堆空间中(或者说,包含这些变量的整个作用域对象被移到了堆上持久化)。
    • 如上图所示,clourse(foo) (即 inner) 在栈里,但它手里紧紧攥着一个地址 1003
    • 地址 1003 指向堆空间里的一个对象,里面赫然躺着 { myName: "极客时间", test1: 1 }

第三阶段:调用闭包

当我们调用 bar() (即 inner) 时:

  1. 引擎创建 inner 的执行上下文。
  2. 代码遇到 console.log(myName)
  3. 引擎在当前上下文没找到 myName
  4. 它顺着作用域链(那个秘密武器),找到了堆里地址 1003 对应的环境。
  5. 成功读取:"极客时间"。

🧠 闭包本质总结: 闭包不是某种特殊的语法,而是函数与其词法环境的组合

  • 普通函数:用完即走,栈帧清空,数据消失。
  • 闭包函数:因为“有人”(外部引用)还需要它内部的变量,所以引擎不敢清空栈帧,而是把这些变量打包扔到堆里长期保存,直到没人再需要这个函数为止。
  • 代价:这些变量会一直占用内存,直到 bar = null 断开引用,垃圾回收器才会来清理。

第五章:一图胜千言 —— 总结对比

为了让您彻底清晰,我们把刚才的分析浓缩成一张对比表:

特性 基本类型 (1.js) 引用类型 (2.js) 闭包 (5.html/6.html)
存储位置 只在栈 栈存地址,堆存实体 变量被强行保留在堆
赋值行为 值拷贝 (复印文件) 引用拷贝 (复制遥控器) 作用域捕获 (带走整个房间)
修改影响 互不影响 互相影响 (改的是同一份数据) 内部函数可读写外部私有变量
生命周期 函数结束即销毁 对象无引用时被 GC 回收 比定义它的函数活得更久
形象比喻 两个独立的苹果 两个人看同一个投影 把家里的家具搬到了公共仓库

💡 给开发者的终极建议

  1. 处理基本类型:放心大胆地赋值,不用担心改了一个影响另一个。
  2. 处理对象/数组:小心!b = a 之后,你以为你在操作 b,其实你可能在修改 a 的数据。如果需要独立副本,请使用扩展运算符 [...a]Object.assign 进行深拷贝/浅拷贝
  3. 使用闭包
    • 好处:创造私有变量,模拟类,函数柯里化。
    • 风险:如果不小心在闭包里引用了巨大的 DOM 节点或大对象,且长期不释放,会导致内存泄漏
    • 解决:不需要时,手动将引用置为 null (bar = null),告诉垃圾回收器“可以打扫了”。

希望这次结合内存动态流转生活化比喻的讲解,能让您对 JavaScript 的内存机制和闭包有透彻的理解!如果还有哪个环节觉得不够直观,请随时告诉我,我们可以针对那个点继续深挖。

❌