阅读视图

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

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

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

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


简单来说

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

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


🎯 本节目标

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

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

核心痛点与解决方案

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

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

传统做法 vs LangChain 效率对比

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

传统做法:

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

解决:LangChain 一键生成

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

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

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

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

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

效果对比:

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

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

创建 Agent 就像开咖啡店

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

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

创建基础 Agent 流程

完整代码

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

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

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

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

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

代码解析

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

💡 人话解读

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

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

真实世界 Agent 架构

真实世界的 Agent 需要什么?

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

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

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

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

You have access to two tools:

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

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

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

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

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

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

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

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

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

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

预期输出

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

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

💡 人话解读

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

核心组件详解

1. 系统提示(System Prompt)

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

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

好的系统提示的特点:

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

2. 工具(Tools)

工具的结构:

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

工具的参数:

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

3. 结构化输出(Response Format)

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

为什么要用?

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

使用方法:

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

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

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

4. 记忆(Memory)

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

如何使用?

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

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

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

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

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

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

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


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

Agent 业务场景应用

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

示例:客服助手

工具:

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

系统提示:

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

使用:

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

常见问题与解决方案

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

💡 调试技巧

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

总结对比表

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

核心要点回顾

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

JavaScript

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

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

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

app.listen(3000);

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

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

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

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

HTTP

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

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

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

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

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

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

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

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


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

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

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

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

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

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

HTTP

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

image.png

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

什么是模块化

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

模块化的发展历程

石器时代

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

  1. 全局变量污染

  2. 难以管理依赖

  3. 代码组织混乱

如下代码所示:

// a.js
const moment = 1;

// b.js
const moment = 2;

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

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

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

20250526193143

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

IIFE

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

它的基本语法如下所示:

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

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

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

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

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

console.log(typeof a);

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

20250526195433

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

CommonJs

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

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

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

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

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

AMD

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

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

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

require([module], callback);

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

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

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

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

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

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

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

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

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

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

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

console.log(111222);

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

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

20250528080714

UMD

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

20250528083501

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

20250528084015

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

ESM

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

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

深入理解 CommonJS

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

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

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

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

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

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

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

我们编写如下代码:

const foo = 1;

module.exports = { foo };

console.log(module);

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

20250528093757

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

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

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

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

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

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

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

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

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

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

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

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

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

Module._cache = { __proto__: null };

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

Module._pathCache = { __proto__: null };

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

Module._extensions = { __proto__: null };

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

let modulePaths = [];

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

Module.globalPaths = [];

let patched = false;

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

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

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

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

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

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

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

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

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

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

module.exports 和 exports 的关系

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

20250528203423

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

20250528203750

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

如下代码所示:

// utils.js

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

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

module.exports = {
  object,
};

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

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

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

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

20250528204706

验证了我们前面的说法。

CommonJs 读取的模块的缓存

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

如下代码所示:

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

let internalCounter = 0;

function increment() {
  internalCounter++;
}

function getCounter() {
  return internalCounter;
}

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

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

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

// moduleA.js

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

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

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

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

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

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

// moduleB.js

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

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

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

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

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

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

// index.js

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

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

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

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

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

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

20250528210528

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

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

require 查找细节

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

20250528211143

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

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

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

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

如下代码所示:

console.log(module.paths);

20250528211350

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

20250528211516

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

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

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

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

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

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

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

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

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

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

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

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

20250528212004

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

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

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

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

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

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

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

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

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

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

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

    return module.exports;
  }

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

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

小结

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

20250528213120

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

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

深入理解 ES Modules

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

20250528214610

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

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

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

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

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

Module Record

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

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

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

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

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

Module Environment Record

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

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

Es Module 的解析流程

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

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

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

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

Construction 构建阶段

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

20250528215643

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

20250528215735

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

  • JavaScript 代码本身

  • 执行上下文

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

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

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

ImportEntry Records

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

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

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

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

详情可参考下图:

20250528220153

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

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

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

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

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

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

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

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

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

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

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

20250528220636

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

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

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

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

如下图所示:

20250528220855

linking 链接阶段

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

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

20250528221028

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

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

如下图所示:

20250528221222

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

Evaluation 求值阶段

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

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

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

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

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

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

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

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

20250528221954

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

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

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

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

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

20250528221921

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

总结

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

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

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

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

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

image.png

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

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

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

整体结构

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

如下图所示。

20260325075816

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

20260325080415

配置与入口类

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

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

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

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

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

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

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

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

export class FourDimension {
  private inited = false;

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

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

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

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

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

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

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

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

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

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

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

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

如下图所示。

20260325075931

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

缓存与上报时机

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

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

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

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

type ReportPayload = Record<string, unknown>;

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

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

export function flushQueue(reportUrl: string, immediate = false): void {
  if (!queue.length) return;
  const batch = queue.splice(0, queue.length);
  const body = JSON.stringify({ batch });
  if (immediate) {
    sendReport(reportUrl, body);
    return;
  }
  const run = () => sendReport(reportUrl, body);
  if (typeof requestIdleCallback === "function") {
    requestIdleCallback(run, { timeout: 3000 });
  } else {
    setTimeout(run, 0);
  }
}

export function scheduleFlush(reportUrl: string, delayMs = 2000): void {
  if (flushTimer) clearTimeout(flushTimer);
  flushTimer = setTimeout(() => {
    flushTimer = null;
    flushQueue(reportUrl, false);
  }, delayMs);
}

export function bindLifecycleFlush(reportUrl: string): void {
  const onHide = () => {
    if (document.visibilityState === "hidden") {
      flushQueue(reportUrl, true);
    }
  };
  window.addEventListener("pagehide", () => flushQueue(reportUrl, true));
  document.addEventListener("visibilitychange", onHide);
}

getCache 若要对调用方返回快照,需要深拷贝避免外部改数组。深拷贝实现注意处理循环引用以外的普通 JSON 友好结构即可。

性能指标用最新采集思路

PerformanceObserver 仍是采集绘制与布局类指标的主力,buffered: true 让你晚注入脚本也能拿到已经发生过的条目。导航类指标优先读 PerformanceNavigationTiming,比自己在事件里 performance.now() 更贴近浏览器统计。

在挂 observer 之前可以用静态方法探测当前环境到底支持哪些 entryTypes,避免 observe 直接抛错。下面是一段可放进工具模块的探测逻辑。

export function supportedPerfTypes(): string[] {
  if (typeof PerformanceObserver !== "function") return [];
  return PerformanceObserver.supportedEntryTypes ?? [];
}

export function canObserve(type: string): boolean {
  return supportedPerfTypes().includes(type);
}

Chrome DevToolsPerformanceLighthouse 里跑一遍同页,把面板里的 LCPCLS 与 SDK 打上去的值对比,数量级应一致。若差一个数量级,先查是否重复统计、是否在 iframe 里采集、是否混用了导航时间与绘制时间。

Core Web Vitals 对齐

截至 Google 面向站长的公开说明,Core Web Vitals 核心指标是 LCPINPCLSFID 已被 INP 取代,自研 SDK 仍可同时上报 FID 做历史对比,但产品解读应以 INP 为主。

指标 含义 推荐采集方式
LCP 视口内最大内容绘制完成时刻 PerformanceObservertype: 'largest-contentful-paint',通常取最后一次有效条目
INP 交互到下一帧绘制的延迟分布 PerformanceObservertype: 'interaction'(需较新 Chromium),或引入 web-vitals
CLS 累计布局偏移 PerformanceObservertype: 'layout-shift',且只统计 hadRecentInput === false 的条目并累加 value

FPFCP 仍可通过 type: 'paint' 观察,用于诊断首屏是否"空刷背景"与"首现有意义内容"的差异。

三个核心指标与采集入口的关系,适合印在团队 wiki 首页当速查图。

如下图所示。

20260325080035

LCPINPCLS 与对应 observer 类型名称的对应关系。

paint 与首屏绘制

下面示例合并监听 first-paintfirst-contentful-paint,并在拿到 FCP 后断开,避免重复回调。若你希望两种 paint 都上报,应在两种都见到后再 disconnect,或干脆不断开、由服务端按 paintName 去重。

import { enqueue, scheduleFlush } from "./queue";
import { getConfig } from "./config";

function safeObserverSupported(): boolean {
  return typeof PerformanceObserver !== "undefined";
}

export function observePaint(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (
        entry.name !== "first-paint" &&
        entry.name !== "first-contentful-paint"
      )
        continue;
      const json = entry.toJSON();
      enqueue({
        type: "performance",
        subType: "paint",
        paintName: entry.name,
        startTime: json.startTime,
        pageURL: location.href,
      });
      if (entry.name === "first-contentful-paint") {
        obs.disconnect();
        scheduleFlush(getConfig().reportUrl);
        break;
      }
    }
  });
  obs.observe({ type: "paint", buffered: true });
}

LCP 在页面生命周期内可能更新,规范语义是"最后一个汇报的 LCP 条目代表当前候选"。简单实现可以在回调里每次都上报最新一条,由服务端取同会话最后一次,或在客户端只保留最大 startTime 的那条再上报。注意 LCP 回调触发时 entry.element 可能已被移除,DOM 引用要谨慎,上报 tagName 与资源 URL 即可。

export function observeLcp(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    const entries = list.getEntries() as PerformanceEntry[];
    const last = entries[entries.length - 1] as LargestContentfulPaint &
      PerformanceEntry;
    const json = last.toJSON();
    enqueue({
      type: "performance",
      subType: "lcp",
      startTime: json.startTime,
      element: last.element?.tagName,
      url: "url" in last ? String((last as { url?: string }).url ?? "") : "",
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "largest-contentful-paint", buffered: true });
}

上面用到 LargestContentfulPaint 时,若项目 lib.dom 较旧,可把 last 标成 PerformanceEntry 并谨慎读取可选字段。

CLSINP

CLS 需要过滤用户操作附近的偏移,避免把有意交互造成的布局变化算成体验问题。

export function observeCls(): void {
  if (!safeObserverSupported()) return;
  let clsScore = 0;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceEntry[]) {
      const ls = entry as LayoutShift & {
        hadRecentInput?: boolean;
        value?: number;
      };
      if (ls.hadRecentInput) continue;
      clsScore += ls.value ?? 0;
      enqueue({
        type: "performance",
        subType: "cls",
        value: ls.value,
        cumulativeLayoutShift: clsScore,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "layout-shift", buffered: true });
}

INP 依赖 type: 'interaction'PerformanceObserver,浏览器支持面仍在演进。生产环境若要省心,可直接使用 web-vitals 包,它会在不支持时降级或给出兼容策略。最小接入示意如下,真实项目里把 console.log 换成 enqueue 即可。

import { onINP } from "web-vitals";

onINP((metric) => {
  const v = metric.value;
  console.log("INP ms", v);
});

自研最小实现可以封装为"支持则订阅,不支持则不上报",避免把未定义行为写死进业务。

导航时间与 DOMContentLoadedload

更稳的做法是读取 performance.getEntriesByType('navigation')[0],得到 PerformanceNavigationTiming,用相对 fetchStartstartTime 的各阶段时刻算 DNSTCPTTFBDOM 解析等。字段含义以 MDN 上的 PerformanceNavigationTiming 为准,换公式前用一次 console.tablenav 打出来核对。

export function collectNavigationTiming(): void {
  const [nav] = performance.getEntriesByType(
    "navigation",
  ) as PerformanceNavigationTiming[];
  if (!nav) return;
  enqueue({
    type: "performance",
    subType: "navigation",
    dns: nav.domainLookupEnd - nav.domainLookupStart,
    tcp: nav.connectEnd - nav.connectStart,
    ttfb: nav.responseStart - nav.requestStart,
    domContentLoaded: nav.domContentLoadedEventEnd - nav.fetchStart,
    load: nav.loadEventEnd - nav.fetchStart,
    pageURL: location.href,
  });
  scheduleFlush(getConfig().reportUrl);
}

可在 load 事件触发后再调用一次,确保 loadEventEnd 已非 0。单页应用在客户端路由切换时不会产生新的 navigation 条目,若要监控"软导航",需要结合框架路由钩子或 Performance API 里仍在演进的软导航相关能力单独设计,不能把 PV 和导航耗时混在一条 navigation 记录里硬解释。

资源耗时

资源条目用 type: 'resource'。注意不要在每个 entry 上都 disconnect,否则只会收到第一条资源。更合理的是页面 load 后一次性读取 performance.getEntriesByType('resource'),或长期观察但在 disconnect 前处理完整批次。

跨域资源若没有正确的 Timing-Allow-Origin,多数细粒度时长在浏览器里会被抹成 0,这是安全策略不是 SDK 坏了。核实方式是对比同源静态资源与 CDN 资源的 transferSizedomainLookupStart 等是否突然全 0。

export function observeResources(): void {
  if (!safeObserverSupported()) return;
  const obs = new PerformanceObserver((list) => {
    for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
      enqueue({
        type: "performance",
        subType: "resource",
        name: entry.name,
        initiatorType: entry.initiatorType,
        duration: entry.duration,
        dns: entry.domainLookupEnd - entry.domainLookupStart,
        tcp: entry.connectEnd - entry.connectStart,
        ttfb: entry.responseStart - entry.requestStart,
        protocol: entry.nextHopProtocol,
        transferSize: entry.transferSize,
        encodedBodySize: entry.encodedBodySize,
        decodedBodySize: entry.decodedBodySize,
        pageURL: location.href,
      });
    }
    scheduleFlush(getConfig().reportUrl);
  });
  obs.observe({ type: "resource", buffered: true });
}

若担心资源量过大,可在客户端按域名白名单或按耗时阈值过滤后再入队。也可按 config.sampleRate 随机丢弃非错误样本,只保留长尾。

接口耗时:fetchXHR

只劫持 XMLHttpRequest 会漏掉现代代码里大量的 fetch。可以同时包装 window.fetchXMLHttpRequest.prototype。包装 fetch 时不要假设调用方不克隆 Response 去读体,监控侧只读 status 与头即可,避免和消费方抢读同一个 body 流。

export function patchFetch(): void {
  const orig = window.fetch.bind(window);
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const start = performance.now();
    const req = input instanceof Request ? input : new Request(input, init);
    try {
      const res = await orig(req);
      const end = performance.now();
      enqueue({
        type: "performance",
        subType: "fetch",
        url: req.url,
        method: req.method,
        status: res.status,
        duration: end - start,
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      return res;
    } catch (err) {
      const end = performance.now();
      enqueue({
        type: "error",
        subType: "fetch",
        url: req.url,
        method: req.method,
        duration: end - start,
        message: err instanceof Error ? err.message : String(err),
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
      throw err;
    }
  };
}

XHR 劫持仍可用 opensend 包装,在 loadend 上打点时间戳,与上文思路一致,此处不重复贴全。

错误上报

资源错误与 JS 运行时错误要分开通道。window.addEventListener('error', …, true) 在捕获阶段能拿到 scriptlinkimg 等加载失败,event.target 指向元素。纯 JS 语法与运行时错误同一事件里 target 往往为空,可配合 window.onerror 或同一监听里分支处理。ErrorEvent 上的 message 在跨域脚本且未正确配置 crossorigin 时可能是统一口令,需要和源站 CORS 配置一起核实。

Promise 未处理拒绝用 unhandledrejection。上报体里尽量带 reason 的栈信息,字符串化时注意大对象。

事件路径不要用已弃用的 event.path,改用 event.composedPath()

错误从页面钻进队列前,按类型分流,便于后端路由到不同看板。

如下图所示。

20260325080152

资源、脚本、Promise 三类错误进入同一条上报管道前的分流意象。

function elementPath(ev: Event): string[] {
  const path = typeof ev.composedPath === "function" ? ev.composedPath() : [];
  return path
    .filter((n): n is Element => n instanceof Element)
    .map((el) => el.tagName);
}

export function initGlobalErrorHandlers(): void {
  window.addEventListener(
    "error",
    (ev) => {
      const t = ev.target;
      if (
        t &&
        t instanceof HTMLElement &&
        (t instanceof HTMLImageElement ||
          t instanceof HTMLScriptElement ||
          t instanceof HTMLLinkElement)
      ) {
        const url =
          "src" in t && t.src ? t.src : "href" in t && t.href ? t.href : "";
        enqueue({
          type: "error",
          subType: "resource",
          url,
          tag: t.tagName,
          paths: elementPath(ev),
          pageURL: location.href,
        });
        scheduleFlush(getConfig().reportUrl);
        return;
      }
      if (!ev.message) return;
      enqueue({
        type: "error",
        subType: "js",
        message: ev.message,
        filename: ev.filename,
        lineno: ev.lineno,
        colno: ev.colno,
        stack: ev.error instanceof Error ? ev.error.stack : "",
        pageURL: location.href,
      });
      scheduleFlush(getConfig().reportUrl);
    },
    true,
  );

  window.addEventListener("unhandledrejection", (ev) => {
    const reason = ev.reason;
    enqueue({
      type: "error",
      subType: "promise",
      stack: reason instanceof Error ? reason.stack : String(reason),
      pageURL: location.href,
    });
    scheduleFlush(getConfig().reportUrl);
  });
}

若担心第三方脚本堆栈污染,可在入口做采样或域名过滤。生产环境应上传 source map 到私有桶,由服务端按 release 解析栈,而不是把完整文件路径暴露给前端库。

行为数据:PV、停留时长、点击

PV 在每次路由或首屏进入时打一条,带上 document.referrer 与本地生成的会话或设备标识。UV 必须在服务端用 cookie、登录 id 或可信指纹聚合,客户端只能提供匿名 id。单页应用要在路由变化时手动调一次 reportPv,仅依赖首屏加载会严重低估。

停留时长用 visibilitychange 记录可见累计时间,比只在 beforeunload 减一次更准,尤其是后台标签与 bfcache 场景。离开页面时再发一条汇总,字段里带 visibleMs 即可。下面是一段与队列解耦的计时思路,需与上文的 enqueueflushQueuegetConfig 同模块配合使用。

import { enqueue, flushQueue } from "./queue";
import { getConfig } from "./config";

let visibleAccum = 0;
let lastVisibleStart = performance.now();

document.addEventListener("visibilitychange", () => {
  const now = performance.now();
  if (document.visibilityState === "visible") {
    lastVisibleStart = now;
  } else {
    visibleAccum += now - lastVisibleStart;
  }
});

window.addEventListener("pagehide", () => {
  if (document.visibilityState === "visible") {
    visibleAccum += performance.now() - lastVisibleStart;
  }
  enqueue({
    type: "behavior",
    subType: "dwell",
    visibleMs: Math.round(visibleAccum),
    pageURL: location.href,
  });
  flushQueue(getConfig().reportUrl, true);
});

点击监听建议防抖,避免长按或滑动误触暴风上报。坐标与 outerHTML 体积要限长,防止队列爆炸。敏感页面不要上传完整 outerHTML,可只保留 data- 业务埋点键名。

下面用 sessionStorage 存会话 id,首次访问时用 crypto.randomUUID() 生成。若需兼容极老环境,可再降级到时间戳加长随机串。

function createSessionId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

let sessionId = sessionStorage.getItem("fd_sid") ?? "";
if (!sessionId) {
  sessionId = createSessionId();
  sessionStorage.setItem("fd_sid", sessionId);
}

export function reportPv(): void {
  enqueue({
    type: "behavior",
    subType: "pv",
    pageURL: location.href,
    referrer: document.referrer,
    sessionId,
  });
  scheduleFlush(getConfig().reportUrl);
}

export function reportClickDebounced(delayMs = 500): void {
  let timer: ReturnType<typeof setTimeout> | null = null;
  window.addEventListener("pointerdown", (ev) => {
    if (!(ev.target instanceof Element)) return;
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      timer = null;
      const el = ev.target;
      const r = el.getBoundingClientRect();
      enqueue({
        type: "behavior",
        subType: "click",
        tag: el.tagName,
        x: r.left,
        y: r.top,
        paths: elementPath(ev),
        pageURL: location.href,
        sessionId,
      });
      scheduleFlush(getConfig().reportUrl);
    }, delayMs);
  });
}

上线前建议核对的一张表

把下面几项当成发布前 checklist,在 Chrome 与一种目标内核(如 Safari 或内置浏览器)各测一遍。

核对项 怎么核实 常见坑
sendBeacon 是否到达 Network 里看 report 请求体与状态码 跨域未放行 POST413 体积过大
LCP 是否合理 Lighthouse 与 SDK 数值同页对比 iframe、影子根、元素已移除
资源耗时是否全 0 挑一条 CDN 资源看 responseStart Timing-Allow-Origin
软导航 PV 手动点路由后看是否产生新 pv 事件 只监听了首次 load
重复 flush 快速切换标签看上报条数是否翻倍 visibilitypagehide 未去重

小结

把上报做成"队列加空闲 flush 加离开兜底",用 sendBeacon 携带 JSON Blob,性能侧用 PerformanceObserverPerformanceNavigationTiming 对齐现代指标,并补上 CLSINP 的采集意识,错误侧区分资源与脚本并改用 composedPath,行为侧把 PV、软导航与可见停留时间说清楚,就是一个可演进的最小监控采集层。存储与查询、告警与大盘属于下一篇文章。

使用 Hooks 构建无障碍 React 组件

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

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

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

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

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

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

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

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

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

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

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

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

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

把 JavaScript 原型讲透:从 `[[Prototype]]`、`prototype` 到 `constructor` 的完整心智模型

目录

  • 引言:为什么原型是前端工程师绕不过去的一课
  • 一、先建立统一认知:对象原型到底是什么
  • 二、prototype[[Prototype]] 不是一回事
  • 三、从 new 和内存视角理解实例、构造函数与原型
  • 四、函数原型上的高频知识点:共享属性与 constructor
  • 五、重写原型对象时,为什么最容易踩坑
  • 六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype
  • 实战建议
  • 总结:关键结论与团队落地建议

引言:为什么原型是前端工程师绕不过去的一课

很多团队在日常开发里已经很少手写“构造函数 + 原型”这套模式了,更多时候我们写的是 class、对象字面量、组合式函数,甚至直接用框架帮我们屏蔽底层细节。于是原型这件事,常常只在面试里出现,看起来像“八股”,但一旦线上排查问题,它又会突然变得非常真实:

  • 为什么两个实例的方法地址相同?
  • 为什么给对象赋值后没有覆盖到原型上的值?
  • 为什么重写 prototype 之后,constructor 看起来“不对了”?
  • 为什么控制台里 __proto__ 看起来什么都有,但代码里又不建议用它?
  • 为什么 class 最终仍然离不开原型链?

如果对这些问题没有统一心智模型,工程上就会出现两类常见问题:一类是“会用但讲不清”,另一类是“改得动但不敢改”。而原型真正的价值,不在于背定义,而在于帮助我们理解 JavaScript 的对象系统、继承机制、方法共享、内存结构,以及很多框架设计背后的语言基础。

这篇文章的目标很明确:不是把概念堆给你,而是把“对象、函数、构造函数、实例、原型、构造器”这几者之间的关系,一次性串起来。读完之后,你至少应该能建立起一个稳定的判断标准:什么应该挂在实例上,什么应该挂在原型上,什么时候可以重写原型,重写后又要补什么。


一、先建立统一认知:对象原型到底是什么

在 JavaScript 中,几乎每个对象都带着一个隐藏的内部链接,这个内部链接在规范里叫 [[Prototype]]。它会指向另一个对象,而这个“被指向的对象”,就是当前对象的原型对象。

你可以把它理解成:当前对象在找不到某个属性时,下一站该去哪里找。

1. 原型最核心的作用:兜底查找

当我们访问一个对象属性时,会触发内部的 [[Get]] 过程;当我们给对象设置属性时,会触发 [[Set]] 过程。

操作 触发时机 原型参与方式
[[Get]] 读取属性时 先查对象自身,找不到再沿原型向上查
[[Set]] 设置属性时 优先看当前对象及属性描述符,再决定是否在当前对象创建新属性

下面这个例子最能说明问题:

function A() {}
A.prototype.x = 10

const obj = new A()

console.log(obj.x) // 10,obj 自身没有 x,沿原型找到 A.prototype.x

obj.x = 20
console.log(obj.x) // 20,此时 obj 自身已经有了 x

这里发生了两件事:

  1. 第一次读 obj.x,对象自身没有,沿着原型找到 A.prototype.x
  2. 第二次写 obj.x = 20,是在实例自身新增了一个同名属性,而不是改掉原型上的 x

这也是很多人第一次理解“共享”和“遮蔽(shadowing)”的关键入口。

2. 对象字面量创建出来的对象,也有原型

很多人以为只有通过构造函数创建出来的对象才有原型,这其实不对。只要是普通对象,通常都有 [[Prototype]]

const obj = { name: 'XiaoWu' }
const foo = {}

console.log(obj.__proto__)
console.log(foo.__proto__)

图:隐式原型在浏览器与终端中的表现

控制台里你看到的结果,和真实运行时的内部结构并不完全等价。浏览器控制台为了方便调试,会把一些继承来的内容也展开给你看;Node 的输出则更接近“对象本身 + 原型关系”的表现。

从理解层面,可以先把它抽象成下面这样:

const obj = { name: 'XiaoWu', __proto__: {} }
const foo = { __proto__: {} }

当然,真正的 [[Prototype]] 不是你字面量里真的写出来的这个字段,而是引擎内部维护的链接关系。

3. __proto__[[Prototype]]Object.getPrototypeOf 到底什么关系?

这是高频混淆点,必须一次说清:

  • [[Prototype]]:规范层面的内部槽,真实存在,但你不能直接写代码访问这个名字
  • __proto__:历史遗留的访问器属性,调试方便,但不推荐作为正式代码依赖
  • Object.getPrototypeOf(obj):标准 API,推荐在正式代码里使用
const obj = { name: '小吴' }

console.log(Object.getPrototypeOf(obj))

调试场景里,obj.__proto__ 确实更顺手;工程代码里,优先使用 Object.getPrototypeOf(obj)。原因很简单:

  • 语义标准、跨环境更稳定
  • 可维护性更高
  • 降低“我在操作语言底层 hack 口子”的心智负担

顺手补一句:今天的引擎几乎都支持 __proto__,但“能用”不等于“应该作为主路径使用”。

本章小结

  • 每个对象的核心原型关系,体现在内部的 [[Prototype]]
  • 读取属性找不到时,会沿原型继续查找
  • 给实例赋值,不等于改原型;很多时候只是“在实例自身新增同名属性”
  • __proto__ 更适合调试,正式代码优先 Object.getPrototypeOf
  • 理解原型,本质是在理解 JavaScript 如何做“属性查找”和“能力复用”

二、prototype[[Prototype]] 不是一回事

聊原型最容易踩的第一个坑,就是把 prototype[[Prototype]] 混为一谈。它们名字很像,但角色完全不同。

1. prototype 是函数身上的属性,不是所有对象都有

先看例子:

function foo() {}

const obj = {}

console.log(foo.prototype) // 普通函数默认有 prototype
console.log(obj.prototype) // undefined,普通对象没有 prototype

这里有一个非常重要的判断标准:

  • prototype 是函数对象上的一个属性,主要给“作为构造函数使用”时服务
  • [[Prototype]] 是对象内部的原型链接,普通对象、函数对象都可能有

也就是说:

  • 函数是对象,所以函数也有 [[Prototype]]
  • 但普通对象不是函数,所以普通对象没有 prototype

2. 这两个概念各自负责什么?

可以直接用一句最工程化的话来理解:

  • prototype定义将来由这个构造函数创建出来的实例,应该共享什么
  • [[Prototype]]当前这个对象,实际沿哪条链路去查找属性

它们的职责并不重复:

  1. 归属不同
    prototype 属于函数;[[Prototype]] 属于对象

  2. 作用不同
    prototype 用来定义共享能力;[[Prototype]] 用来参与查找路径

  3. 时机不同
    prototype 通常在定义阶段配置;[[Prototype]] 通常在对象创建时被确定

3. 纠正一个特别容易出现的误区

很多人在刚学到这里时,会误以为:

“函数自己的隐式原型会指向它自己的显式原型”

这是错误的。

准确关系应该是:

  • foo.prototype:给将来 new foo() 出来的实例用
  • Object.getPrototypeOf(foo):函数对象 foo 自己的原型,通常是 Function.prototype

也就是说:

function foo() {}

console.log(Object.getPrototypeOf(foo) === Function.prototype) // true

而实例和构造函数之间的正确关系,是下一节的重点:

const f1 = new foo()
console.log(Object.getPrototypeOf(f1) === foo.prototype) // true

4. new 到底做了什么?

理解原型,绕不开 new。把它拆开看,会清晰很多。

new Foo() 大致会做下面几步:

  1. 创建一个全新的空对象
  2. 把这个对象的 [[Prototype]] 指向 Foo.prototype
  3. 用这个新对象作为 this 执行构造函数
  4. 如果构造函数没有显式返回对象,就返回这个新对象

所以,实例为什么能访问构造函数原型上的方法?答案就在第 2 步。

function Foo() {}

const f1 = new Foo()
const f2 = new Foo()

console.log(Object.getPrototypeOf(f1) === Foo.prototype) // true
console.log(Object.getPrototypeOf(f2) === Foo.prototype) // true

这就是为什么不同实例可以“共享一套方法定义”,却又拥有各自不同的数据。

本章小结

  • prototype[[Prototype]] 名字相似,但职责完全不同
  • 普通对象没有 prototype,函数通常有
  • 实例的 [[Prototype]] 会在 new 时指向构造函数的 prototype
  • 函数对象自己的原型通常是 Function.prototype,不是它自己的 prototype
  • 只要把“定义共享能力”和“参与属性查找”分开理解,很多混乱都会消失

三、从 new 和内存视角理解实例、构造函数与原型

如果只停留在语法层,原型会越学越抽象。真正把它看懂,最有效的方式是换成“引用关系”和“内存指向”的视角。

1. Person、实例对象和原型对象之间是什么关系?

先看一个最简单的例子:

function Person() {}

console.log(Person.prototype)

很多人看到这里会困惑:Person 是函数,Person.prototype 是对象,那实例和它们之间是怎么连起来的?

关键结论只有一个:

同一个构造函数创建出来的实例,默认会共享同一个原型对象。

这也是后面方法复用的基础。

图:从控制台结果理解构造函数与原型对象的关系

这张图适合帮助我们建立第一个直觉:构造函数不是孤立存在的,它天然带着一个 prototype 对象。

2. 为什么 p1p2 可以访问同一套原型内容?

function Person() {}

const p1 = new Person()
const p2 = new Person()

这里最值得记住的不是“创建了两个实例”,而是“这两个实例的原型指向同一个地方”。

console.log(Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2)) // true
console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:p1p2 实例对象共享同一个原型对象

这就解释了一个很重要的工程现象:

  • Person.prototype.xxx
  • 实际上影响的是所有还指向这个原型对象的实例
function Person() {}

const p1 = new Person()
const p2 = new Person()

console.log(Object.getPrototypeOf(p1) === Person.prototype) // true
console.log(Object.getPrototypeOf(p2) === Person.prototype) // true

图:通过相等比较验证实例原型是否一致

3. 一个很适合面试和排错的思考题:p1.name 到底能从哪里拿到?

假设 p1 自身没有 name,那 p1.name 还能不能拿到值?

答案是能,而且方式不止一种。本质上,这些方式最终都在改同一个共享原型对象。

function Person() {}

const p1 = new Person()
const p2 = new Person()

Object.getPrototypeOf(p1).name = '小吴'
console.log(p1.name) // 小吴

Person.prototype.name = 'XiaoWu'
console.log(p1.name) // XiaoWu

Object.getPrototypeOf(p2).name = 'why'
console.log(p1.name) // why

为什么第三种改 p2 的原型,也会影响 p1

因为:

Object.getPrototypeOf(p1) === Object.getPrototypeOf(p2) === Person.prototype

它们最终都指向同一个共享对象。

把这个关系进一步抽象成“内存地址”,就更容易理解了。你可以把上面的变化想成:

// 假设共享原型对象就像一个地址 0x100
0x100.name = '小吴'
console.log(0x100.name) // 小吴

0x100.name = 'XiaoWu'
console.log(0x100.name) // XiaoWu

0x100.name = 'why'
console.log(0x100.name) // why

图:从“内存指向”视角理解实例、构造函数与原型的关系

这个视角非常关键,因为后面理解“共享方法”“重写原型”“原型链继承”时,本质都是在理解引用关系,而不是背结论。

本章小结

  • 同一个构造函数创建的实例,默认共享同一个原型对象
  • Object.getPrototypeOf(p1) === Person.prototype 是原型学习中的第一条黄金验证公式
  • 改共享原型,相当于影响所有还连接到它的实例
  • 原型问题一旦抽象成“引用地址”,很多现象都会变得很好解释
  • 面试里问“为什么改 p2 的原型会影响 p1”,本质在考你是否理解“共享引用”

四、函数原型上的高频知识点:共享属性与 constructor

前面讲的是“为什么原型存在”,这一节讲“原型上通常放什么”。

1. 原型上放的是“共享能力”

在 JavaScript 中,函数的 prototype 对象,本质上就是给实例共享用的。

function Person() {}

Person.prototype.name = 'why'
Person.prototype.age = 18

const p1 = new Person()
const p2 = new Person()

console.log(p1.name, p2.age) // why 18

这意味着:

  • nameage 不在 p1p2 自身上
  • 它们来自共享原型
  • 所有实例都能访问,但并不各自拷贝一份

图:往原型上添加共享属性后的结构示意

这里顺便给一个工程建议:
如果一个值会因实例不同而不同,就不要放原型上;如果一段行为对所有实例都一致,就优先考虑放原型上。

2. constructor 是什么?为什么平时看不见?

默认情况下,函数的原型对象上会有一个 constructor 属性,它指回构造函数本身。

function Foo() {}

console.log(Foo.prototype.constructor === Foo) // true

但很多同学在控制台直接打印 Foo.prototype 时,看见的是个空对象,于是误以为它什么都没有。其实不是没有,而是:

constructor 默认是不可枚举的。

所以直接打印、遍历时看不明显,但你可以通过属性描述符把它“看见”。

function Foo() {}

console.log(Foo.prototype) // 看起来像 {}
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

图:在 Node 中查看 constructor 的真实属性描述符

3. constructor 存在的意义是什么?

constructor 的工程意义,不是“让你炫技”,而是帮我们保留一条从原型对象追溯回构造函数的路径。

function Foo() {}

console.log(Foo.prototype.constructor.name) // Foo

这相当于让原型系统形成了一个闭环:

  • 实例通过 [[Prototype]] 指向原型对象
  • 原型对象通过 constructor 指回构造函数

这条关系能帮助我们做理解、调试和某些类型判断。但也要注意一点:

constructor 可以被改写,所以它不是绝对可靠的类型判断依据。

在工程里,如果你想做类型判断:

  • 优先考虑 instanceof
  • 或者基于更稳定的品牌判断方式
  • 不要把 constructor 当成唯一真理

4. 一个有意思但不建议滥用的闭环验证

function Foo() {}

console.log(
  Foo.prototype.constructor.prototype.constructor.prototype.constructor.name
) // Foo

这段代码能跑通,不是因为 JavaScript 神秘,而是因为这条引用关系本来就存在。
不过知道就好,别把它写进业务代码里。

本章小结

  • 原型对象最适合承载共享属性和共享方法
  • constructor 默认存在于函数原型对象上,只是不可枚举
  • Foo.prototype.constructor === Foo 是默认成立的
  • constructor 适合理解原型结构,但不适合作为唯一类型判断依据
  • 共享逻辑放原型,是 JavaScript 节省内存、复用能力的关键设计

五、重写原型对象时,为什么最容易踩坑

前面讲的是“给现有原型追加内容”,这一节讲的是另一种更激进的操作:直接重写整个原型对象。

1. 什么叫“重写原型对象”?

不是这样:

Person.prototype.name = '小吴'
Person.prototype.age = 20

而是这样:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 20,
  learn() {
    console.log(this.name + '在学习')
  }
}

这种写法在属性比较多时很常见,结构也更集中。

先看原始的“构造函数与原型相互关联”视角:

图:默认原型对象与构造函数之间的关联

当你执行 Person.prototype = { ... } 时,本质上是让 Person.prototype 指向了一个全新的对象

图:重写原型后,构造函数指向了新的原型对象

继续把内容填进去之后,新的结构才完整:

图:新的原型对象被填充内容后的状态

2. 这里最容易掉的坑:constructor 丢了

看下面的代码:

function Person() {}

Person.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

const f1 = new Person()
console.log(f1.name + '今年' + f1.age) // 小吴今年18

功能看起来没问题,但有一个隐藏变化:

console.log(Person.prototype.constructor === Person) // false
console.log(Person.prototype.constructor === Object) // true

原因并不复杂:

  • 默认创建函数时,引擎会为它生成一个带 constructor 的原型对象
  • 但你手动赋值的新对象只是一个普通对象字面量
  • 它自己的 constructor 并不是 Person
  • 查找时会沿着这个新对象的原型往上找到 Object.prototype.constructor

图:重写原型后,实例仍能访问属性,但 constructor 关系已发生变化

3. 正确做法:手动把 constructor 补回去

最常见的补法如下:

function Foo() {}

Foo.prototype = {
  name: '小吴',
  age: 18,
  height: 1.88
}

Object.defineProperty(Foo.prototype, 'constructor', {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Foo
})

const f1 = new Foo()
console.log(f1.name + '今年' + f1.age)

为什么不用下面这种简单写法?

Foo.prototype = {
  constructor: Foo,
  name: '小吴'
}

因为这样写出来的 constructor 默认是可枚举的,而原生默认行为里,这个属性应该是不可枚举的。
如果你想尽量保持和原生行为一致,Object.defineProperty 更合适。

图:补回 constructor 后,构造函数与新原型对象重新闭合

4. 再补一个容易忽略的边界条件

很多人以为“重写原型后,旧原型会立即消失”,这其实不严谨。

更准确的说法是:

  • 如果旧原型对象已经没有任何可达引用,后续才可能被垃圾回收
  • 如果已有实例还指向旧原型,那旧原型仍然活着

例如:

function Person() {}

const oldP = new Person()

Person.prototype = {
  sayHello() {
    console.log('hello')
  }
}

const newP = new Person()

console.log(Object.getPrototypeOf(oldP) === Object.getPrototypeOf(newP)) // false

这在排查“为什么新老实例行为不一致”时非常关键。

本章小结

  • prototype 追加属性,和直接重写整个 prototype,是两种不同操作
  • 重写原型后,默认的 constructor 关联会丢失
  • 推荐用 Object.definePropertyconstructor 补回去
  • 重写原型不会自动“更新”旧实例的原型指向
  • 原型对象是否回收,取决于是否还有引用,而不是“看起来不用了”

六、创建对象的推荐姿势:实例数据放 this,共享方法放 prototype

这是原型章节里最重要的工程落点。

1. 一个典型错误:把实例数据塞进共享原型

下面这段代码看似“想省事”,实则会制造共享数据污染:

function Person(name, age, sex, address) {
  Person.prototype.name = name
  Person.prototype.age = age
  Person.prototype.sex = sex
  Person.prototype.address = address
}

const p1 = new Person('小吴', 18, '男', '福建')
console.log(p1.name) // 小吴

const p2 = new Person('why', 35, '男', '广州')
console.log(p1.name) // why

为什么 p1.name 最后变成了 why

因为你不是把数据放进 p1p2 自身,而是放进了它们共享的 Person.prototype
这等于让所有实例共用一份可变数据,自然后创建的实例会覆盖前一个实例的结果。

这类问题在工程里很致命,因为它会造成一种非常糟糕的现象:对象看起来是独立的,实际状态却是串联的。

2. 正确做法:实例数据归实例,共享方法归原型

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

Person.prototype.eating = function () {
  console.log(this.name + '今天吃烤地瓜了')
}

Person.prototype.running = function () {
  console.log(this.name + '今天跑了五公里')
}

const p1 = new Person('小吴', 18, '男', '福建')
const p2 = new Person('why', 35, '男', '广州')

console.log(p1.name) // 小吴
console.log(p2.name) // why
console.log(p1.eating === p2.eating) // true

这套写法有三个直接收益:

  1. 实例数据隔离
    每个对象维护自己的状态,不会相互覆盖

  2. 方法共享
    所有实例共用同一个方法引用,减少重复创建

  3. 结构清晰
    一眼能分清“对象自己的数据”和“对象共享的行为”

3. 为什么不要把原型方法写进构造函数内部?

有些代码会这么写:

function Person(name) {
  this.name = name
  this.eating = function () {
    console.log(this.name + '在吃东西')
  }
}

它不是不能运行,而是有明显代价:每次 new Person() 都会重新创建一个新的函数对象。

如果实例特别多,这就是实打实的重复内存占用和不必要的函数分配。

更合理的方式还是:

function Person(name) {
  this.name = name
}

Person.prototype.eating = function () {
  console.log(this.name + '在吃东西')
}

4. 这套模式和 class 有什么关系?

如果你已经在写 class,那更应该理解这部分。因为:

class Person {
  constructor(name) {
    this.name = name
  }

  eating() {
    console.log(this.name + '在吃东西')
  }
}

本质上仍然是:

  • constructor 里放实例数据
  • 方法定义在原型上

class 改变的是写法,不是底层原理。

本章小结

  • 实例间不同的数据,放 this
  • 所有实例共享的行为,放 prototype
  • 不要把可变实例数据放到共享原型上
  • 不要在构造函数里重复创建所有实例都相同的方法
  • 理解这条原则后,再看 class 会非常顺手

实战建议

1. 代码评审时重点看这几件事

  • 是否把实例级数据错误地挂到了原型上
  • 是否把共享方法错误地定义在构造函数内部
  • 是否在重写 prototype 后忘了补 constructor
  • 是否在正式代码里依赖 __proto__ 而不是标准 API
  • 是否出现“旧实例”和“新实例”指向不同原型的潜在风险

2. 调试原型问题时,建议这样验证

console.log(Object.getPrototypeOf(obj))
console.log(Object.getPrototypeOf(obj) === Foo.prototype)
console.log(obj.hasOwnProperty('xxx'))
console.log('xxx' in obj)
console.log(Object.getOwnPropertyDescriptors(Foo.prototype))

这一组排查动作,足够覆盖大多数原型相关问题:

  • 属性是自己的,还是继承来的
  • 当前实例到底连到哪个原型对象
  • 原型对象上的属性描述符是否符合预期
  • constructor 是否被改坏了

3. 团队内可以落地的约束

  • 约定:实例状态一律放 this / 类字段
  • 约定:共享方法统一放原型 / 类方法
  • 约定:禁止在业务代码里直接依赖 __proto__
  • 约定:重写 prototype 必须同步恢复 constructor
  • 约定:在 Code Review Checklist 中加入“原型污染”和“共享引用”检查项

4. 性能与可维护性的权衡

  • 小量对象场景下,差异可能不明显
  • 大量实例场景下,方法是否共享会带来真实内存差异
  • 动态改原型虽然灵活,但会明显增加维护成本
  • 原型越“魔法化”,后续新人接手成本越高

总结:关键结论与团队落地建议

JavaScript 的原型并不神秘,它本质上解决的是两个问题:

  1. 对象找不到属性时,去哪里继续找
  2. 多个实例如何共享同一套行为定义

把这两件事想清楚,原型就不再是零散知识点,而是一套完整的对象模型。

最后用几条结论收尾:

  • [[Prototype]] 是对象的查找链路,prototype 是构造函数为实例准备的共享模板
  • new 的关键一步,是把实例的 [[Prototype]] 指向构造函数的 prototype
  • constructor 默认存在于原型对象上,只是不可枚举
  • 重写 prototype 会改变后续实例的继承来源,同时可能破坏 constructor
  • 最稳妥的工程实践是:实例数据放 this,共享方法放 prototype

如果要在团队内部继续往下沉淀,建议下一步把下面几个主题串起来学习:

  • 原型链完整查找过程
  • instanceof 的底层判断逻辑
  • Object.create 与显式指定原型
  • 组合继承、寄生组合继承
  • class extends 背后的原型链本质

当你把这些知识连起来之后,JavaScript 的对象系统就不再是“记忆题”,而会变成你分析框架、阅读源码、设计抽象时的一套底层能力。

React 拖拽:无需第三方库的完整方案

拖拽是用户期望"理所当然能用"的交互之一。无论是对任务看板重新排序、通过拖动文件上传,还是让用户在仪表盘中重新排列小组件,抓取并移动的操作都让人感觉自然流畅。然而大多数 React 教程一上来就引入像 react-dnddnd-kit 这样的重量级库——它们功能强大,但对许多常见场景来说增加了过多的包体积和概念负担。

如果只需一次 Hook 调用就能获得流畅、可用于生产的拖拽行为呢?本文将从原生浏览器 API 出发,分析它们为何难用,然后用 ReactUse 中的两个轻量 Hook:useDraggableuseDropZone 来解决同样的问题。

手动实现:自行处理指针事件

让元素可拖拽的最基本方式是手动监听 pointerdownpointermovepointerup 事件。通常的写法如下:

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

function ManualDraggable() {
  const ref = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const [isDragging, setIsDragging] = useState(false);
  const delta = useRef({ x: 0, y: 0 });

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

    const onPointerDown = (e: PointerEvent) => {
      const rect = el.getBoundingClientRect();
      delta.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
      setIsDragging(true);
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!isDragging) return;
      setPosition({
        x: e.clientX - delta.current.x,
        y: e.clientY - delta.current.y,
      });
    };

    const onPointerUp = () => setIsDragging(false);

    el.addEventListener("pointerdown", onPointerDown);
    window.addEventListener("pointermove", onPointerMove);
    window.addEventListener("pointerup", onPointerUp);

    return () => {
      el.removeEventListener("pointerdown", onPointerDown);
      window.removeEventListener("pointermove", onPointerMove);
      window.removeEventListener("pointerup", onPointerUp);
    };
  }, [isDragging]);

  return (
    <div
      ref={ref}
      style={{
        position: "fixed",
        left: position.x,
        top: position.y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
      }}
    >
      拖动我
    </div>
  );
}

能跑起来——但看看你需要管理多少状态。而这还只是最简单的版本。实际需求会迅速叠加更多复杂性。

为什么手动实现拖拽很难

上面的代码片段有几个不足之处,一旦超出 Demo 级别就会立刻暴露出来:

  1. 容器边界。 如果你想让元素保持在父容器内部,就需要在每次移动时读取容器尺寸并限制位置。这意味着每帧都要在两个元素上调用 getBoundingClientRect

  2. 指针类型。 上面的代码处理了鼠标事件,但触控和手写笔呢?PointerEvent API 统一了它们,但按指针类型过滤(例如禁止手写笔拖动)需要额外的条件判断。

  3. 拖拽手柄。 有时可拖拽的触发区域只是卡片内部的一个标题栏。你需要将"触发"元素和"移动"元素分离,并相应地连接事件。

  4. 事件清理。 忘记移除监听器——或者在 useEffect 中使用了错误的依赖——会导致诸如松开鼠标后元素仍在移动之类的隐蔽 Bug。

  5. 放置区域。 HTML5 拖放 API 引入了 dragenterdragoverdragleavedrop 事件。协调这些事件——尤其是子元素上臭名昭著的 dragenter/dragleave 闪烁问题——非常容易出错。

这些正是 useDraggableuseDropZone 开箱即用要解决的问题。

useDraggable:一个 Hook,完全掌控

useDraggable 接受一个目标元素的 ref 和一个可选的配置对象。它返回当前的 xy 位置、一个表示元素是否正在被拖拽的布尔值,以及一个 setter(用于程序化地移动元素)。

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

function DraggableCard() {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    initialValue: { x: 100, y: 100 },
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        cursor: isDragging ? "grabbing" : "grab",
        padding: 16,
        background: isDragging ? "#4338ca" : "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        transition: isDragging ? "none" : "box-shadow 0.2s",
        boxShadow: isDragging ? "0 8px 24px rgba(0,0,0,0.2)" : "none",
        userSelect: "none",
        touchAction: "none",
      }}
    >
      随意拖动我
    </div>
  );
}

这就是整个组件。无需手动事件监听器。无需清理逻辑。触控、鼠标和手写笔默认都能工作。

限制在容器内

传入一个 containerElement ref,Hook 会自动夹紧位置,使元素不会离开容器:

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

function BoundedDrag() {
  const container = useRef<HTMLDivElement>(null);
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(el, {
    containerElement: container,
    initialValue: { x: 0, y: 0 },
  });

  return (
    <div
      ref={container}
      style={{
        position: "relative",
        width: 400,
        height: 300,
        border: "2px dashed #cbd5e1",
        borderRadius: 8,
      }}
    >
      <div
        ref={el}
        style={{
          position: "absolute",
          left: x,
          top: y,
          width: 80,
          height: 80,
          background: "#4f46e5",
          borderRadius: 8,
          cursor: isDragging ? "grabbing" : "grab",
          touchAction: "none",
        }}
      />
    </div>
  );
}

无需手动的夹紧计算。Hook 会读取容器的滚动和客户端尺寸,自动限制元素位置。

使用拖拽手柄

通常你只想让元素的特定部分——比如一个标题栏——触发拖拽。传入 handle ref 即可:

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

function DraggablePanel() {
  const panel = useRef<HTMLDivElement>(null);
  const handle = useRef<HTMLDivElement>(null);

  const [x, y, isDragging] = useDraggable(panel, {
    handle,
    initialValue: { x: 200, y: 150 },
  });

  return (
    <div
      ref={panel}
      style={{
        position: "fixed",
        left: x,
        top: y,
        width: 280,
        background: "#fff",
        borderRadius: 8,
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
        overflow: "hidden",
        touchAction: "none",
      }}
    >
      <div
        ref={handle}
        style={{
          padding: "8px 12px",
          background: "#4f46e5",
          color: "#fff",
          cursor: isDragging ? "grabbing" : "grab",
          userSelect: "none",
        }}
      >
        从这里拖动
      </div>
      <div style={{ padding: 12 }}>
        <p>此内容区域不会触发拖拽。</p>
      </div>
    </div>
  );
}

面板的主体仍然是可交互的——你可以选择文本、点击按钮或滚动——而只有标题栏是拖拽触发器。

useDropZone:轻松实现文件拖放

useDropZone 解决拖放的另一半:接收放置。它处理全部四个拖拽事件(dragenterdragoverdragleavedrop),阻止浏览器默认打开文件的行为,并通过内部计数器解决了 dragleave 闪烁问题。

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

function FileUploader() {
  const dropRef = useRef<HTMLDivElement>(null);
  const [files, setFiles] = useState<File[]>([]);

  const isOver = useDropZone(dropRef, (droppedFiles) => {
    if (droppedFiles) {
      setFiles((prev) => [...prev, ...droppedFiles]);
    }
  });

  return (
    <div
      ref={dropRef}
      style={{
        padding: 40,
        border: `2px dashed ${isOver ? "#4f46e5" : "#cbd5e1"}`,
        borderRadius: 8,
        background: isOver ? "#eef2ff" : "#f8fafc",
        textAlign: "center",
        transition: "all 0.15s",
      }}
    >
      {isOver ? (
        <p>松开以上传</p>
      ) : (
        <p>将文件拖到这里上传</p>
      )}
      {files.length > 0 && (
        <ul style={{ textAlign: "left", marginTop: 16 }}>
          {files.map((f, i) => (
            <li key={i}>
              {f.name} ({(f.size / 1024).toFixed(1)} KB)
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

isOver 布尔值让你在文件进入时立即重新设置区域样式,给用户清晰的视觉反馈。无需 e.preventDefault() 样板代码,不用和闪烁的 dragleave 事件斗争。

构建看板风格的卡片拖动

让我们在一个更贴近实际的例子中结合两个 Hook——一个可拖拽的卡片,松开时弹回原位,以及一个接受它的放置区域。我们还将使用 useElementBounding 来读取区域位置以做视觉反馈。

import { useDraggable, useDropZone, useElementBounding } from "@reactuses/core";
import { useRef, useState } from "react";

interface Task {
  id: string;
  title: string;
}

function KanbanBoard() {
  const [todo, setTodo] = useState<Task[]>([
    { id: "1", title: "设计原型" },
    { id: "2", title: "编写 API 规范" },
  ]);
  const [done, setDone] = useState<Task[]>([
    { id: "3", title: "搭建 CI 流水线" },
  ]);

  const doneZoneRef = useRef<HTMLDivElement>(null);
  const todoZoneRef = useRef<HTMLDivElement>(null);

  const isOverDone = useDropZone(doneZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const isOverTodo = useDropZone(todoZoneRef, (files) => {
    // 此示例忽略文件拖放
  });

  const doneBounds = useElementBounding(doneZoneRef);

  return (
    <div style={{ display: "flex", gap: 24, padding: 24 }}>
      <div>
        <h3>待办</h3>
        <div
          ref={todoZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverTodo ? "#fef3c7" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {todo.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onDrop={() => {
                setTodo((prev) => prev.filter((t) => t.id !== task.id));
                setDone((prev) => [...prev, task]);
              }}
              targetBounds={doneBounds}
            />
          ))}
        </div>
      </div>
      <div>
        <h3>完成</h3>
        <div
          ref={doneZoneRef}
          style={{
            minHeight: 200,
            padding: 12,
            background: isOverDone ? "#d1fae5" : "#f1f5f9",
            borderRadius: 8,
          }}
        >
          {done.map((task) => (
            <div
              key={task.id}
              style={{
                padding: 12,
                marginBottom: 8,
                background: "#fff",
                borderRadius: 6,
                boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
              }}
            >
              {task.title}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

function TaskCard({
  task,
  onDrop,
  targetBounds,
}: {
  task: Task;
  onDrop: () => void;
  targetBounds: ReturnType<typeof useElementBounding>;
}) {
  const el = useRef<HTMLDivElement>(null);

  const [x, y, isDragging, setPosition] = useDraggable(el, {
    initialValue: { x: 0, y: 0 },
    onEnd: (pos) => {
      // 检查卡片是否在"完成"列上方释放
      if (
        targetBounds &&
        pos.x >= targetBounds.left &&
        pos.x <= targetBounds.right &&
        pos.y >= targetBounds.top &&
        pos.y <= targetBounds.bottom
      ) {
        onDrop();
      }
      // 弹回原始位置
      setPosition({ x: 0, y: 0 });
    },
  });

  return (
    <div
      ref={el}
      style={{
        position: "relative",
        left: x,
        top: y,
        padding: 12,
        marginBottom: 8,
        background: isDragging ? "#e0e7ff" : "#fff",
        borderRadius: 6,
        boxShadow: isDragging
          ? "0 8px 24px rgba(0,0,0,0.15)"
          : "0 1px 3px rgba(0,0,0,0.1)",
        cursor: isDragging ? "grabbing" : "grab",
        zIndex: isDragging ? 50 : 1,
        touchAction: "none",
        userSelect: "none",
        transition: isDragging ? "none" : "all 0.2s ease",
      }}
    >
      {task.title}
    </div>
  );
}

几个值得注意的关键点:

  • useElementBounding 为我们提供了"完成"列的实时 leftrighttopbottom 值,以便在拖拽结束时进行碰撞检测。
  • onEnd 回调在未落在目标上时将卡片弹回 { x: 0, y: 0 }。配合 CSS transition 产生令人满意的橡皮筋效果。
  • 无需外部状态库。React 的 useState 对于这个复杂度完全够用。

配合其他 Hook 增强体验

ReactUse 的 Hook 天然可组合。以下是扩展上述示例的几种方式:

  • useMouse ——全局追踪光标位置,在拖拽过程中显示自定义拖拽光标或跟随指针的浮动提示。
  • useEventListener ——附加一个 keydown 监听器,在用户按下 Escape 时取消拖拽。
  • useElementSize ——动态读取容器的宽高以计算网格对齐位置(例如将 x 舍入到单元格宽度的最近倍数)。

例如,使用 useEventListener 添加 Escape 取消只需几行代码:

import { useDraggable, useEventListener } from "@reactuses/core";
import { useRef } from "react";

function CancelableDrag() {
  const el = useRef<HTMLDivElement>(null);
  const [x, y, isDragging, setPosition] = useDraggable(el);

  useEventListener("keydown", (e: KeyboardEvent) => {
    if (e.key === "Escape" && isDragging) {
      setPosition({ x: 0, y: 0 });
    }
  });

  return (
    <div
      ref={el}
      style={{
        position: "fixed",
        left: x,
        top: y,
        padding: 16,
        background: "#4f46e5",
        color: "#fff",
        borderRadius: 8,
        cursor: isDragging ? "grabbing" : "grab",
        touchAction: "none",
      }}
    >
      拖动我(按 Esc 重置)
    </div>
  );
}

什么时候仍然需要完整的库

useDraggableuseDropZone 用最少的代码覆盖了绝大多数拖放场景。然而,如果你的需求包含复杂的可排序列表(带动画过渡)、具有键盘无障碍访问的多容器排序,或包含上千项的虚拟化列表,像 dnd-kit 这样的专用库仍然是更好的选择。关键在于,你并不需要在每种情况下都引入一个——对许多项目来说,一对 Hook 就足够了。

安装

npm i @reactuses/core

相关 Hook


ReactUse 提供了 100+ 个 React Hook。探索所有 Hook →


本文最初发布于 ReactUse 博客

深入浅出 AST:解密 Vite、Babel编译的底层“黑盒”

前言

在前端开发中,我们每天都在写 JSX、TypeScript、Vue SFC,但浏览器其实根本看不懂这些。是谁把这些高级语法翻译成了浏览器能执行的 JS?答案就是 AST(Abstract Syntax Tree,抽象语法树) 。它是所有前端构建工具(Vite、Webpack、ESBuild、Babel)的灵魂。

一、 核心概念:什么是 AST?

AST(Abstract Syntax Tree,抽象语法树) ,是代码的结构化数据表示。简单来说,就是把原本一行行纯文本形式的代码,剥离无关的格式、空格、注释等冗余信息,转换成一棵有层级、有嵌套、有明确语法逻辑的树状对象。

  • 转换的核心意义:让计算机能够真正读懂代码的含义,而不是把代码当成普通字符串处理。有了AST,机器才能精准分析代码结构、修改代码逻辑、实现各类编译构建功能。

  • 例子const a = 1 在 AST 中会被拆解为:一个变量声明节点、一个标识符 a 和一个数字字面量 1


二、 AST的编译与生成流程

代码转换通常经历以下四个标准阶段:

  1. 词法分析 (Tokenization) :将长字符串拆解为最小语法单元(Tokens)。例如把 const a = 1 拆成 consta=1

  2. 语法分析 (Parsing) :在通过词法分析得到零散的Tokens后,语法分析会根据对应的语言规范(JS规范、Vue模板规范等),将这些无序的Tokens按照语法规则,组装成具有嵌套依赖关系的节点树,也就是最终的AST。这一步会确立代码的语法结构,比如声明语句、赋值语句、函数定义等节点的层级关系。

  3. 转换 (Transformation) :这是各类编译工具的核心工作区,比如Babel、ESBuild、Rollup的关键逻辑都在这一步。工具会深度遍历AST上的每一个节点,根据需求对节点进行修改、新增、删除操作,比如语法降级、代码替换、依赖处理等,改造出符合目标要求的新AST。

  4. 代码生成 (Code Generation) :完成AST的修改后,最后一步就是逆向操作:把改造后的树状AST,重新转换回纯文本形式的可执行代码,完成整个编译构建流程。


三、AST的核心应用场景

AST是前端工程化的底层基石,几乎所有主流的构建、转译、优化工具,都是基于AST实现的,核心应用场景包括:

  • 代码转译(ES6+转ES5、TS转JS、Vue/React编译)
  • 依赖预构建与依赖分析
  • Tree Shaking(无用代码剔除)
  • 代码压缩、混淆、格式化
  • 静态代码检查(ESLint)
  • 框架单文件组件编译(Vue SFC、React JSX)

四、 AST 在 Vite 中的降维打击

Vite作为新一代前端构建工具,凭借超快的启动速度和构建效率出圈,而这一切高效能力的底层,都离不开AST的支撑。下面详解AST在Vite四大核心场景中的具体作用。

1. 依赖预构建 (Pre-bundling)

依赖预构建是Vite启动速度远超Webpack的核心秘诀,而AST则是依赖预构建的核心底层支撑,具体执行流程:

  1. Vite会深度解析第三方依赖包代码(比如lodash-es、axios等),先将代码文本转换为AST;
  2. 遍历AST节点,精准识别出所有 import/export 语句(或CommonJS的 require 语句),梳理清楚第三方包的内部依赖关系;
  3. 修改AST节点:将不兼容浏览器的CommonJS语法,转换成浏览器原生支持的ESM模块化语法;
  4. 继续优化AST,把零散的多个依赖文件,合并成少数几个文件,减少网络请求;
  5. 将修改后的AST重新生成代码文本,缓存到 node_modules/.vite 目录下,供浏览器直接加载。

2. ESBuild 转译

Vite在开发阶段选用Go 编写的 ESBuild 进行快如闪电的转译,实现TS转JS、ES6+语法降级等能力,而ESBuild的核心工作原理就是基于AST处理

  1. ESBuild读取TS/TSX源码,将其解析生成标准AST;
  2. 遍历AST节点,剔除TS特有的语法节点(比如类型注解const a: number = 1),保留纯JS逻辑;
  3. 对ES6+高阶语法节点(箭头函数、解构赋值、可选链等)进行转换,替换为ES5兼容的AST节点;
  4. 将转换后的AST生成纯JS代码文本,返回给浏览器加载执行。

3. 按需导入与 Tree Shaking

Vite生产环境打包底层基于Rollup,而Tree Shaking(剔除无用代码、实现按需引入)完全依赖AST实现:

  1. Rollup解析项目源码,生成完整的AST;
  2. 深度遍历AST,跟踪代码的引用关系,精准识别出未被调用、未被引用的无用代码节点(比如未使用的函数、变量、模块);
  3. 从AST中直接删除这些无用节点,精简AST结构;
  4. 将精简后的AST重新生成代码文本,大幅减少打包体积,实现代码瘦身。

4. Vue SFC 单文件组件编译

在Vite+Vue项目中,@vitejs/plugin-vue 插件负责解析.vue单文件组件,AST是整个编译流程的核心:

  1. 插件先将.vue文件拆分为 <template><script><style> 三大核心模块;
  2. 针对 <template> 模板:生成专属的Vue模板AST(结构类似JS AST,针对模板语法优化),再将模板AST进一步转换成渲染函数(render函数)对应的JS AST;
  3. 针对 <script setup> 脚本:解析JS AST,处理 definePropsdefineEmitsdefineExpose 等Vue语法糖,将其转换为浏览器可识别的普通JS代码;
  4. 最后合并所有模块的AST,生成浏览器可直接运行的完整JS代码,完成Vue组件编译。

📝 总结与启发

AST 是前端工程化的“上帝视角”。掌握了它,你就掌握了编写 Lint 工具、代码加密、自动重构脚本 以及 自定义 Babel/Vite 插件 的能力。

前端模块化:CommonJS、AMD、ES Module三大规范全解析

前言

在前端工程化日益庞大的今天,模块化已成为基石。从最初的“全局变量污染”到如今的“万物皆可模块”,前端社区经历了漫长的探索。本文将深度解析业界主流的三大模块规范:CommonJSAMDES Module

一、 CommonJS:服务端的先行者

CommonJS 是最早正式提出的 JavaScript 模块规范,伴随着 Node.js 的诞生而风靡。

1. 核心语法

  • 导出:使用 module.exportsexports
  • 导入:使用 require
// a.js
const add = (a, b) => a + b;
module.exports = { add };

// main.js
const { add } = require('./a.js');
console.log(add(1, 2));

2. 局限性与挑战

  • 环境依赖:模块加载器由 Node.js 提供,高度依赖运行时环境。
  • 同步阻塞:CommonJS 规定模块加载是同步的。在服务端(磁盘读取)这没问题,但在浏览器端(网络请求),同步加载会导致 JS 解析阻塞,造成页面假死。

二、 AMD:浏览器的异步解法

为了解决 CommonJS 在浏览器端的同步阻塞问题,AMD (Asynchronous Module Definition) 应运而生。

1. 核心语法

AMD规范依赖第三方库(如RequireJS)实现,通过 define() 函数定义模块:第一个参数声明依赖模块数组,第二个参数为回调函数,依赖加载完成后执行;模块导出通过return实现。

// print.js 定义无依赖的模块
define(function () {
  // 模块内部逻辑
  function print(msg) {
    console.log("print " + msg);
  }
  // return 导出模块成员
  return {
    print
  };
});

// main.js 定义有依赖的模块
// 第一个参数:依赖模块列表;第二个参数:依赖加载完成后的回调
define(["./print"], function (printModule) {
  // 使用依赖模块的方法
  printModule.print("main");
});

2. 存在的不足

  • 非原生支持:需要引入第三方的 loader(如著名的 RequireJS)。
  • 开发成本:书写格式相对复杂,代码逻辑被包裹在回调函数中,阅读和维护成本较高。

三、 ES Module (ESM):终极统一方案

ES Module(ESM) 是ECMAScript官方推出的模块化标准,也是目前现代前端工程化的唯一标准,浏览器和Node.js均已原生支持,完美解决了前两种规范的缺陷。

1. 核心语法

  • 导出exportexport default
  • 导入import
// lib.js
export const version = '1.0.0';
export default function MyFunc() {}

// main.js
import MyFunc, { version } from './lib.js';

2. 为什么它是最优解?

  • 编译时加载(静态分析) :ESM 在代码执行前就能确定模块依赖关系,这使得 Tree-shaking(摇树优化) 成为可能。
  • 原生支持:现代浏览器通过 <script type="module"> 即可直接运行,无需转换。
  • 异步加载:天然支持异步,不会阻塞页面渲染。

四、 核心对比:CommonJS vs AMD vs ESM

维度 CommonJS AMD ES Module
加载方式 同步加载 异步加载 静态编译/异步加载
运行环境 主要用于服务端 (Node.js) 浏览器端 (需 Loader) 浏览器/服务端通用
典型代表 Node.js RequireJS Vite, Webpack, 现代浏览器

五、 总结与趋势

  1. CommonJS 依然是 Node.js 生态的基石,但在向 ESM 过渡。
  2. AMD 已逐渐退出历史舞台,基本被打包工具(如 Webpack)内部处理。
  3. ESM 是未来,无论是前端框架(Vue3/React)还是构建工具(Vite),都在全面拥抱 ESM。

异步编程:从“回调地狱”到“async/await”的救赎之路

JavaScript是单线程的,但它却能同时处理很多事情。这是怎么做到的?今天我们就来聊聊异步编程,看看JS是怎么一边听歌一边刷网页的。从最原始的回调函数,到Promise,再到优雅的async/await,这不仅是技术的演进,更是一场“程序员不熬夜”的运动。

前言

你有没有经历过这种绝望:写了一个网络请求,结果后面的代码先执行了,请求的数据还没回来,页面已经渲染完了,一片空白。或者你见过这样的代码:

getUser(function(user) {
  getOrders(user.id, function(orders) {
    getOrderDetails(orders[0].id, function(details) {
      getProductInfo(details.productId, function(product) {
        console.log(product);
      });
    });
  });
});

这就是传说中的回调地狱——代码像楼梯一样往右歪,看得人头晕眼花。

今天我们就来走一遍JS异步编程的进化史,看看前辈们是怎么从地狱里爬出来的。

一、为什么需要异步?

JavaScript是单线程的,也就是说同一时间只能做一件事。如果所有事情都排队等着,那遇到一个耗时操作(比如网络请求、读取文件),整个页面就得卡住,用户点哪儿都没反应。

异步就是解决方案:遇到耗时操作,先丢给浏览器或Node去“慢慢做”,JS主线程继续执行后面的代码。等耗时操作完成了,再通知JS:“嘿,我完事了,你处理一下结果吧。”

这就好比你点外卖:你不会站在店门口干等一小时,而是该干嘛干嘛,等外卖小哥打电话叫你,你再去取餐。异步就是这种“不干等”的机制。

二、回调函数:异步的原始形态

回调函数是最早的异步解决方案:把一个函数作为参数传给另一个函数,等异步操作完成后调用这个函数。

function fetchData(callback) {
  setTimeout(() => {
    callback('数据来了');
  }, 1000);
}

fetchData(function(data) {
  console.log(data); // 一秒后输出:数据来了
});

看起来还行,对吧?但一旦有多个依赖的异步操作,就出事了。

回调地狱长什么样?

// 先获取用户
getUser(function(user) {
  // 再根据用户ID获取订单
  getOrders(user.id, function(orders) {
    // 再获取第一个订单的详情
    getOrderDetails(orders[0].id, function(details) {
      // 再根据商品ID获取商品信息
      getProductInfo(details.productId, function(product) {
        // 终于拿到了
        console.log(product);
      });
    });
  });
});

代码往右飞,一眼看不到头。这还没算错误处理——每个回调都要处理错误,代码量直接翻倍。这种代码别说维护了,写的时候自己都要绕晕。

回调的痛点

  • 嵌套太深,代码可读性差
  • 错误处理困难,每个回调都要try-catch
  • 难以并行执行多个异步操作

三、Promise:打破地狱的“链式反应”

ES6引入了Promise,它像是一个“承诺”:现在还没有结果,但将来一定会有(要么成功,要么失败)。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('数据来了');
    // 如果出错:reject('错误信息')
  }, 1000);
});

promise
  .then(data => {
    console.log(data);
  })
  .catch(error => {
    console.error(error);
  });

Promise最大的好处是链式调用,可以把嵌套的异步操作拍平:

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getOrderDetails(orders[0].id))
  .then(details => getProductInfo(details.productId))
  .then(product => console.log(product))
  .catch(error => console.error(error));

看,从“右飞”变成了“下飞”,代码清晰多了。

Promise的几个关键点

  1. 状态不可逆:Promise有三种状态:pending(进行中)、fulfilled(成功)、rejected(失败)。一旦从pending变成fulfilled或rejected,就不能再变了。

  2. 链式传递then返回的是一个新的Promise,所以可以一直链下去。

  3. 错误冒泡:只要链尾有一个catch,前面任何一个环节出错都会落进来。

  4. 并行操作Promise.all等待所有完成,Promise.race等待最快的一个。

// 并行请求
Promise.all([fetchUser(), fetchOrders(), fetchProduct()])
  .then(([user, orders, product]) => {
    console.log('全部完成', user, orders, product);
  });

Promise解决了回调地狱的问题,但还是有些繁琐——你需要写很多.then.catch,而且处理复杂的逻辑时,还是有点绕。

四、async/await:异步代码同步写

ES2017推出的async/await,是Promise的语法糖,让异步代码看起来像同步代码一样直观。

async function getProductInfo() {
  try {
    const user = await getUser();
    const orders = await getOrders(user.id);
    const details = await getOrderDetails(orders[0].id);
    const product = await getProductInfo(details.productId);
    console.log(product);
  } catch (error) {
    console.error(error);
  }
}

关键点

  • async标记的函数返回一个Promise
  • await后面跟一个Promise,它会“暂停”函数执行,直到Promise出结果
  • 错误处理直接用try/catch,和同步代码一模一样

这感觉就像:终于可以用写同步代码的姿势写异步了!不用再管什么then、catch,代码一下子就清爽了。

但注意:await会阻塞函数内部,但不阻塞外部

async function test() {
  console.log('1');
  await new Promise(resolve => setTimeout(resolve, 1000));
  console.log('2'); // 一秒后才输出
}
console.log('3');
test();
console.log('4');
// 输出顺序:1,3,4,(一秒后)2

await只阻塞它所在的async函数,外面的代码照常执行。这正是异步的精髓:不干等。

五、事件循环:异步背后的幕后黑手

说了这么多,你有没有想过一个问题:异步操作完成之后,回调是怎么被调用的?这就要提到**事件循环(Event Loop)**了。

JS的执行机制大概是这样的:

  1. 主线程执行同步代码,遇到异步任务(比如setTimeout、网络请求)就交给Web APIs(浏览器)或libuv(Node)去处理。
  2. 异步任务完成后,回调函数被放入任务队列
  3. 主线程的同步代码执行完后,会不断从任务队列里取回调来执行。
  4. 这个过程不断重复,就是事件循环。

任务队列还分宏任务微任务

  • 宏任务:setTimeout、setInterval、I/O操作、UI渲染
  • 微任务:Promise.then、MutationObserver、queueMicrotask

执行顺序是:一个宏任务 → 所有微任务 → 渲染(如果有) → 下一个宏任务。

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

为什么?同步代码先执行(1,4)→ 微任务Promise.then(3)→ 下一个宏任务setTimeout(2)。

六、实战:封装一个带超时的fetch

我们来用async/await封装一个实用的网络请求函数:

async function fetchWithTimeout(url, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, { signal: controller.signal });
    clearTimeout(timeoutId);
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error('请求超时');
    }
    throw error;
  }
}

// 使用
try {
  const data = await fetchWithTimeout('https://api.example.com/data', 3000);
  console.log(data);
} catch (error) {
  console.error(error.message);
}

这个函数既支持超时控制,又有完善的错误处理,用起来就像同步代码一样简单。

七、异步编程的最佳实践

  1. 能用async/await就用:比原生Promise更易读,错误处理也更自然。

  2. 避免“忘掉await”:忘记await会得到一个Promise对象,而不是实际值,这个bug很难找。

  3. 并行任务用Promise.all:如果多个异步任务互不依赖,用Promise.all并行执行,而不是挨个await。

// 慢:串行执行,总耗时2秒
const user = await getUser();
const orders = await getOrders();

// 快:并行执行,总耗时1秒(如果每个请求1秒)
const [user, orders] = await Promise.all([getUser(), getOrders()]);
  1. 错误处理要完整:async/await用try/catch,Promise用.catch(),不要漏掉。

  2. 避免在循环里用await:除非你确实需要串行执行,否则可以用Promise.all或for...of配合异步。

// 这样会串行执行,很慢
for (const id of ids) {
  const item = await fetchItem(id);
  items.push(item);
}

// 并行执行,快很多
const items = await Promise.all(ids.map(id => fetchItem(id)));

八、总结:从地狱到天堂

JS异步编程的演进史,就是一部程序员与复杂性抗争的历史:

  • 回调函数:原始但容易陷入地狱
  • Promise:链式调用打破嵌套
  • async/await:让异步代码回归同步的直觉

现在,你应该能理解为什么异步这么重要,以及怎么优雅地处理异步了。记住:不要在回调里写回调,不要在地狱里挣扎,用Promise和async/await解救自己。

明天我们将深入JS的另一座大山——事件循环(Event Loop),彻底搞懂微任务、宏任务、渲染时机这些核心概念。到时候你会发现,那些让人头疼的异步面试题,不过是一层窗户纸。

如果你觉得今天的异步进化史讲得通透,点个赞让更多人看到。有疑问评论区见,我们明天见!

# 手把手教你从零搭建 AI 对话系统 - React + Spring Boot 实战(二)

一个完整的类 ChatGPT 对话系统,支持流式输出、打断,会话历史,前后端分离架构,非常适合拿来练手熟悉技术实现或者面试使用,接上一篇前端

基于 Spring Boot 2.7.18 + MyBatis-Plus + JWT 的 AI 对话系统后端服务。

技术栈

  • Spring Boot 2.7.18 - 核心框架
  • MyBatis-Plus 3.5.5 - ORM 框架
  • JWT - 身份认证
  • MySQL 8.0 - 数据存储
  • DeepSeek API - AI 对话能力
  • Knife4j - API 文档

核心功能

1. 用户认证体系

采用 JWT Token 实现无状态认证:

// JwtUtil.java - Token 生成与验证
@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private Long expiration;

    public String generateToken(Long userId, String username) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
}

2. 用户级 API Key 管理

每个用户独立绑定自己的 DeepSeek API Key,存储于 user.api_key 字段:

// UserServiceImpl.java - 注册时保存用户 API Key
@Override
@Transactional
public void register(RegisterRequest request) {
    User user = new User();
    user.setUsername(request.getUsername());
    user.setPassword(passwordEncoder.encode(request.getPassword()));
    user.setApiKey(request.getApiKey());  // 用户专属 API Key
    user.setStatus(1);
    save(user);
}

3. 流式对话实现

通过 SSE (Server-Sent Events) 实现流式响应:

// AiChatServiceImpl.java - 流式对话核心逻辑
@Override
public void streamChat(ChatRequest request, Long userId, HttpServletResponse response) {
    // 1. 从用户获取 API Key
    User user = userService.getById(userId);
    String apiKey = user.getApiKey();

    // 2. 设置 SSE 响应头
    response.setContentType("text/event-stream");
    response.setCharacterEncoding("UTF-8");

    // 3. 调用 DeepSeek API
    HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
    conn.setRequestProperty("Authorization", "Bearer " + apiKey);

    // 4. 流式转发响应
    try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(conn.getInputStream()))) {
        String line;
        while ((line = reader.readLine()) != null) {
            writer.write(line + "\n");
            writer.flush();

            // 解析内容保存到数据库
            if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {
                parseAndSaveContent(line, aiMessage);
            }
        }
    }
}

4. 会话与消息管理

  • 会话表 (chat_session): 存储对话元数据
  • 消息表 (chat_message): 存储对话内容,支持 reasoning_content 深度思考

5. 打断功能实现

前端通过 AbortController 中断请求,后端检测连接状态:

// 检测客户端是否断开连接
private boolean isClientConnected(HttpServletResponse response, PrintWriter writer) {
    try {
        writer.write("");
        writer.flush();
        return !writer.checkError();
    } catch (Exception e) {
        return false;
    }
}

项目结构

src/main/java/com/webseek/
├── common/          # 通用工具类
│   ├── JwtUtil.java
│   ├── CurrentUser.java
│   └── Result.java
├── config/          # 配置类
│   ├── WebConfig.java
│   └── JwtInterceptor.java
├── controller/      # 控制器层
│   ├── AuthController.java
│   ├── ChatController.java
│   ├── SessionController.java
│   └── UserController.java
├── service/         # 服务层
│   ├── AiChatService.java
│   ├── UserService.java
│   └── impl/
├── entity/          # 实体类
│   ├── User.java
│   ├── ChatSession.java
│   └── ChatMessage.java
├── dto/             # 数据传输对象
│   ├── request/
│   └── response/
└── mapper/          # MyBatis Mapper

数据库表结构

-- 用户表
CREATE TABLE `user` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `username` VARCHAR(50) NOT NULL UNIQUE,
    `password` VARCHAR(100) NOT NULL,
    `nickname` VARCHAR(50),
    `api_key` VARCHAR(500),        -- DeepSeek API Key
    `status` TINYINT DEFAULT 1,
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 会话表
CREATE TABLE `chat_session` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `session_id` VARCHAR(64) NOT NULL UNIQUE,
    `user_id` BIGINT NOT NULL,
    `title` VARCHAR(200) DEFAULT '新对话',
    `model` VARCHAR(50),
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME,
    `update_time` DATETIME
);

-- 消息表
CREATE TABLE `chat_message` (
    `id` BIGINT PRIMARY KEY AUTO_INCREMENT,
    `message_id` VARCHAR(64) NOT NULL UNIQUE,
    `session_id` VARCHAR(64) NOT NULL,
    `user_id` BIGINT NOT NULL,
    `role` VARCHAR(20) NOT NULL,   -- user/assistant
    `content` TEXT,
    `reasoning_content` TEXT,      -- 深度思考内容
    `deleted` TINYINT DEFAULT 0,
    `create_time` DATETIME
);

配置说明

修改 application.yml 中的数据库配置:

spring:
  datasource:
    url: jdbc:mysql://your-host:3306/webseek?useUnicode=true&characterEncoding=utf-8
    username: your-username
    password: your-password

启动方式

# 开发环境
mvn spring-boot:run

# 打包
mvn clean package

# 运行
java -jar target/webseek-backend-1.0.0.jar

API 文档

启动后访问:http://localhost:8090/doc.html

核心设计亮点

  1. 用户级 API Key: 每个用户独立配置,安全隔离
  2. 流式响应: SSE 实现打字机效果,支持实时打断
  3. JWT 认证: 无状态设计,支持水平扩展
  4. 逻辑删除: MyBatis-Plus 自动处理软删除
  5. 深度思考: 支持 DeepSeek-R1 推理模型

源码地址[gitee.com/SongTaoo/re…]

手写一个精简版 Zustand:深入理解 React 状态管理的核心原理

“读源码不是为了造轮子,而是为了更好地驾驭轮子。”
本文将带你从零实现一个功能完整、结构清晰的 Zustand 精简版,并深入剖析其设计哲学与性能优化秘诀。


🌟 为什么是 Zustand?

在 React 生态中,状态管理方案层出不穷。Redux 曾长期占据主流,但其样板代码多、学习曲线陡峭的问题饱受诟病。而 Zustand 凭借极简 API、零模板、自动优化渲染等特性,迅速成为开发者的新宠(GitHub ⭐ 超 30k)。

它的核心优势在于:

  • 无需 Provider,直接 import 使用;
  • 天然支持按需订阅,避免无效重渲染;
  • API 极简,一个 create 搞定一切;
  • 轻量(仅 ~1KB),无依赖。

但你是否想过:Zustand 是如何做到这一切的?

今天,我们就来手写一个精简版 Zustand,揭开它高性能、易用背后的秘密。


🔧 第一步:构建最基础的状态容器

状态管理的核心无非三件事:存、取、改

我们先实现一个最简 Store:

const createStore = (createState) => {
  let state;
  const listeners = new Set();

  // 获取当前状态
  const getState = () => state;

  // 修改状态
  const setState = (partial) => {
    const nextState = typeof partial === 'function' ? partial(state) : partial;
    state = Object.assign({}, state, nextState);
    // 通知所有监听者
    listeners.forEach(listener => listener());
  };

  // 订阅状态变化
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener); // 返回取消订阅函数
  };

  // 初始化状态
  state = createState(setState, getState);

  return { getState, setState, subscribe };
};

关键点解析

  • createState 是用户传入的初始化函数,接收 setget
  • setState 支持传入对象或函数(类似 React 的 useState);
  • 使用 Set 存储监听器,避免重复订阅;
  • 状态变更后,通知所有订阅者 —— 这就是“发布-订阅”模式。

🎣 第二步:让 React 组件能“感知”状态变化

光有 Store 不够,React 组件需要在状态变化时自动重渲染。这就需要一个自定义 Hook。

import { useState, useEffect } from 'react';

const useStore = (api, selector = (state) => state) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe(() => {
      forceUpdate(Math.random()); // 强制更新组件
    });
    return unsubscribe;
  }, []);

  return selector(api.getState());
};

⚠️ 问题来了:这个实现会导致所有使用该 Store 的组件在任意状态变化时都重渲染!这显然违背了 Zustand 的“按需更新”原则。


🚀 第三步:实现“精准订阅”——只在关心的状态变化时更新

Zustand 的核心性能优势在于:组件只订阅自己需要的状态片段

改进思路:

  • 比较 selector 前后的值;
  • 只有当选中的值发生变化时,才触发重渲染。
const useStore = (api, selector) => {
  const [, forceUpdate] = useState(0);

  useEffect(() => {
    const unsubscribe = api.subscribe((newState, oldState) => {
      const newSelected = selector(newState);
      const oldSelected = selector(oldState);
      // 使用 Object.is 进行严格相等比较(处理 NaN、-0 等边界)
      if (!Object.is(newSelected, oldSelected)) {
        forceUpdate(Math.random());
      }
    });
    return unsubscribe;
  }, [selector]); // 注意:selector 应为稳定函数(通常用 useCallback 包裹)

  return selector(api.getState());
};

💡 为什么有效?

  • CountDisplayselector: state => state.count
  • text 变化时,count 未变 → newSelected === oldSelected不重渲染
  • 完美实现细粒度更新

🏗️ 第四步:封装 create 高阶函数,提供开发者友好的 API

Zustand 的魔法入口是 create。它返回一个 既是 Hook 又是 Store API 对象 的函数。

export const create = (createState) => {
  const api = createStore(createState);

  const useBoundStore = (selector) => {
    return useStore(api, selector);
  };

  // 将 Store 的方法(setState, getState 等)挂载到 Hook 上
  Object.assign(useBoundStore, api);

  return useBoundStore;
};

这样设计的好处

  • 在组件中:const count = useStore(state => state.count)
  • 在非组件中(如工具函数、事件回调):useStore.setState({ count: 10 })
  • 一套 API,两种用法,无缝切换

🧪 完整 Demo:验证局部更新效果

const useCounterStore = create((set) => ({
  count: 0,
  text: '初始文本',
  increment: () => set(state => ({ count: state.count + 1 })),
  updateText: (text) => set({ text })
}));

// 只订阅 count
const CountDisplay = () => {
  console.log('CountDisplay 渲染了');
  const count = useCounterStore(state => state.count);
  const increment = useCounterStore(state => state.increment);
  return <div>Count: {count} <button onClick={increment}>+</button></div>;
};

// 只订阅 text
const TextDisplay = () => {
  console.log('TextDisplay 渲染了');
  const text = useCounterStore(state => state.text);
  const updateText = useCounterStore(state => state.updateText);
  return <input value={text} onChange={e => updateText(e.target.value)} />;
};

打开控制台你会发现

  • 点击 “+” 按钮 → 只有 CountDisplay 重新渲染;
  • 修改输入框 → 只有 TextDisplay 重新渲染;
  • 完美隔离,性能拉满!

💡 深度思考:Zustand 为何如此优秀?

  1. 去中心化设计
    无需 Provider 嵌套,状态即模块,天然支持代码分割。
  2. 响应式粒度控制
    通过 selector 实现状态切片订阅,比 Context + useReducer 更高效。
  3. 函数式 + 响应式融合
    set 接收函数支持状态派生,get 支持跨字段计算,灵活又安全。
  4. 极致简洁
    核心代码不足 100 行,却覆盖 90% 场景,体现“少即是多”的哲学。

📌 总结

通过手写 Zustand,我们不仅掌握了:

  • 发布-订阅模式在状态管理中的应用;
  • React 自定义 Hook 与状态同步的技巧;
  • 如何实现精准渲染以提升性能;

更重要的是,理解了优秀库的设计思想简单、专注、可组合

“当你能手写一个库,你就真正拥有了它。”

下次面试被问到 Zustand 原理时,不妨自信地说:
“我不仅用过,我还写过。”

AI协同写作应用-TipTap基础功能

前言

系列教程和源码在飞书文档编写。

本章概述

在本章中,我们将快速上手 Tiptap,从零开始创建一个功能完整的富文本编辑器。你将学会如何安装、配置和使用 Tiptap 的基础功能。

学习目标:

  • 创建一个新的前端项目
  • 安装 Tiptap 及其依赖
  • 创建第一个可用的编辑器
  • 使用 StarterKit 快速添加功能
  • 理解基本配置选项
  • 添加简单的工具栏

前置知识:

  • Node.js 和 npm/pnpm 基础
  • HTML、CSS、JavaScript 基础
  • 基础的命令行操作

预计学习时间: 30-45 分钟


1. 环境准备

1.1 检查 Node.js 版本

Tiptap 需要 Node.js 16+ 版本。

# 检查 Node.js 版本
node --version
# 应该显示 v16.0.0 或更高版本

# 检查 npm 版本
npm --version

如果版本过低,请访问 nodejs.org 下载最新的 LTS 版本。

1.2 选择包管理器

本教程推荐使用 pnpm,它比 npm 更快、更节省磁盘空间。

# 安装 pnpm(如果还没有)
npm install -g pnpm

# 验证安装
pnpm --version

当然,你也可以使用 npm 或 yarn:

# 使用 npm
npm install

# 使用 yarn
yarn add

💡 提示: 本教程的所有命令都使用 pnpm,如果你使用其他包管理器,请相应替换命令。


2. 创建项目

2.1 使用 Vite 创建项目

我们使用 Vite 创建一个 React + TypeScript 项目。

# 创建项目
pnpm create vite tiptap-demo --template react-ts

# 进入项目目录
cd tiptap-demo

# 安装依赖
pnpm install

为什么选择 Vite?

  • ⚡ 极快的启动速度
  • 🔥 热更新(HMR)快速
  • 📦 开箱即用的 TypeScript 支持
  • 🛠️ 现代化的构建工具

2.2 项目结构

创建完成后,项目结构如下:

tiptap-demo/
├── node_modules/
├── public/
├── src/
│   ├── App.css
│   ├── App.tsx
│   ├── main.tsx
│   └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
└── vite.config.ts

2.3 启动开发服务器

pnpm dev

打开浏览器访问 http://localhost:5173,你应该能看到 Vite 的欢迎页面。


3. 安装 Tiptap

3.1 安装核心包

pnpm add @tiptap/react @tiptap/pm @tiptap/starter-kit

包的说明:

包名 版本 大小 说明
@tiptap/react ^2.x ~15KB React 集成包,提供 Hooks 和组件
@tiptap/pm ^2.x ~200KB ProseMirror 核心依赖
@tiptap/starter-kit ^2.x ~30KB 常用扩展集合(15+ 扩展)

总大小: ~245KB(未压缩),~80KB(gzip 压缩后)

3.2 验证安装

检查 package.json 文件,应该能看到:

{
  "dependencies": {
    "@tiptap/pm": "^2.x.x",
    "@tiptap/react": "^2.x.x",
    "@tiptap/starter-kit": "^2.x.x",
    "react": "^18.x.x",
    "react-dom": "^18.x.x"
  }
}

4. 创建第一个编辑器

4.1 清理默认代码

首先,清理 Vite 生成的默认代码。

修改 src/App.tsx

// src/App.tsx
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
    </div>
  )
}

export default App

修改 src/App.css

/* src/App.css */
.app {
  max-width: 900px;
  margin: 0 auto;
  padding: 2rem;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

h1 {
  margin-bottom: 2rem;
  color: #333;
}

4.2 创建编辑器组件

创建 src/Tiptap.tsx 文件:

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'

function Tiptap() {
  const editor = useEditor({
    extensions: [
      StarterKit,
    ],
    content: '<p>Hello World! 🌍</p>',
  })

  return <EditorContent editor={editor} />
}

export default Tiptap

代码解析:

  1. 导入必要的模块

    import { useEditor, EditorContent } from '@tiptap/react'
    import StarterKit from '@tiptap/starter-kit'
    
    • useEditor: React Hook,用于创建编辑器实例
    • EditorContent: React 组件,用于渲染编辑器
    • StarterKit: 包含 15+ 个常用扩展
  2. 创建编辑器实例

    const editor = useEditor({
      extensions: [StarterKit],
      content: '<p>Hello World! 🌍</p>',
    })
    
    • extensions: 配置编辑器使用的扩展
    • content: 初始内容(HTML 格式)
  3. 渲染编辑器

    return <EditorContent editor={editor} />
    
    • EditorContent 组件接收编辑器实例并渲染

4.3 在 App 中使用

修改 src/App.tsx

// src/App.tsx
import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

4.4 添加基础样式

src/App.css 中添加编辑器样式:

/* src/App.css */

/* ... 之前的样式 ... */

/* 编辑器容器样式 */
.tiptap {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  padding: 1rem;
  min-height: 200px;
  outline: none;
  background-color: white;
}

/* 编辑器获得焦点时的样式 */
.tiptap:focus {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

/* 段落样式 */
.tiptap p {
  margin: 0.75rem 0;
  line-height: 1.6;
}

/* 第一个段落不需要上边距 */
.tiptap p:first-child {
  margin-top: 0;
}

/* 最后一个段落不需要下边距 */
.tiptap p:last-child {
  margin-bottom: 0;
}

4.5 测试编辑器

保存所有文件,浏览器应该自动刷新。你应该能看到:

  • 一个带边框的编辑区域
  • 初始内容 "Hello World! 🌍"
  • 可以输入、删除文字
  • 可以使用快捷键(Ctrl+B 加粗、Ctrl+Z 撤销等)

测试清单:

  • ✅ 输入文字
  • ✅ 删除文字
  • ✅ 换行(按 Enter)
  • ✅ 撤销(Ctrl+Z)
  • ✅ 重做(Ctrl+Shift+Z)
  • ✅ 加粗(Ctrl+B)
  • ✅ 斜体(Ctrl+I)

5. 理解 StarterKit

5.1 StarterKit 包含的扩展

StarterKit 是一个扩展集合,包含了最常用的 15+ 个扩展:

Nodes(节点):

  • Document - 文档根节点
  • Paragraph - 段落
  • Text - 文本
  • Heading - 标题(H1-H6)
  • Blockquote - 引用块
  • CodeBlock - 代码块
  • BulletList - 无序列表
  • OrderedList - 有序列表
  • ListItem - 列表项
  • HardBreak - 硬换行
  • HorizontalRule - 水平分割线

Marks(标记):

  • Bold - 加粗
  • Italic - 斜体
  • Strike - 删除线
  • Code - 行内代码

Extensions(功能):

  • History - 撤销/重做
  • Dropcursor - 拖放光标
  • Gapcursor - 间隙光标

5.2 测试 StarterKit 功能

让我们测试一下这些功能。修改初始内容:

const editor = useEditor({
  extensions: [StarterKit],
  content: `
    <h1>欢迎使用 Tiptap</h1>
    <p>这是一个<strong>功能强大</strong>的<em>富文本编辑器</em>。</p>
    <h2>主要特性</h2>
    <ul>
      <li>支持多种文本格式</li>
      <li>可扩展的架构</li>
      <li>优秀的性能</li>
    </ul>
    <blockquote>
      <p>Tiptap 让编辑器开发变得简单而有趣。</p>
    </blockquote>
    <pre><code>const editor = useEditor({ ... })</code></pre>
  `,
})

现在你应该能看到:

  • 标题(H1、H2)
  • 加粗和斜体文字
  • 无序列表
  • 引用块
  • 代码块

5.3 自定义 StarterKit

你可以禁用某些扩展或自定义配置:

const editor = useEditor({
  extensions: [
    StarterKit.configure({
      // 禁用某些扩展
      heading: false,
      
      // 自定义扩展配置
      bulletList: {
        HTMLAttributes: {
          class: 'my-bullet-list',
        },
      },
      
      // 自定义标题级别
      heading: {
        levels: [1, 2, 3],  // 只允许 H1、H2、H3
      },
    }),
  ],
  content: '<p>Hello World!</p>',
})

6. 添加工具栏

现在让我们添加一个简单的工具栏,让用户可以点击按钮来格式化文字。

6.1 创建工具栏组件

修改 src/Tiptap.tsx

// src/Tiptap.tsx
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      {/* 工具栏 */}
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      {/* 编辑器 */}
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

代码解析:

  1. 空值检查

    if (!editor) return null
    

    首次渲染时编辑器可能为 null,需要检查。

  2. Commands 链式调用

    editor.chain().focus().toggleBold().run()
    
    • chain(): 开始链式调用
    • focus(): 让编辑器获得焦点
    • toggleBold(): 切换加粗状态
    • run(): 执行命令链
  3. 检查激活状态

    editor.isActive('bold')
    

    用于高亮当前激活的按钮。

  4. 检查命令可用性

    editor.can().undo()
    

    用于禁用不可用的按钮。

6.2 添加工具栏样式

创建 src/Tiptap.css 文件:

/* src/Tiptap.css */

.editor-container {
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  overflow: hidden;
  background-color: white;
}

/* 工具栏样式 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  padding: 0.75rem;
  background-color: #f9fafb;
  border-bottom: 1px solid #e5e7eb;
}

.toolbar button {
  padding: 0.5rem 0.75rem;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  background-color: white;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  transition: all 0.2s;
}

.toolbar button:hover:not(:disabled) {
  background-color: #f3f4f6;
  border-color: #9ca3af;
}

.toolbar button.is-active {
  background-color: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.toolbar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.toolbar .divider {
  width: 1px;
  background-color: #e5e7eb;
  margin: 0 0.25rem;
}

/* 编辑器内容样式 */
.editor-container .tiptap {
  padding: 1rem;
  min-height: 300px;
  outline: none;
  border: none;
}

.editor-container .tiptap:focus {
  box-shadow: none;
}

/* 标题样式 */
.tiptap h1 {
  font-size: 2rem;
  font-weight: 700;
  margin: 1.5rem 0 1rem;
  line-height: 1.2;
}

.tiptap h2 {
  font-size: 1.5rem;
  font-weight: 600;
  margin: 1.25rem 0 0.75rem;
  line-height: 1.3;
}

.tiptap h3 {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 1rem 0 0.5rem;
  line-height: 1.4;
}

/* 列表样式 */
.tiptap ul,
.tiptap ol {
  padding-left: 1.5rem;
  margin: 0.75rem 0;
}

.tiptap li {
  margin: 0.25rem 0;
}

/* 引用块样式 */
.tiptap blockquote {
  border-left: 3px solid #3b82f6;
  padding-left: 1rem;
  margin: 1rem 0;
  color: #6b7280;
  font-style: italic;
}

/* 代码块样式 */
.tiptap pre {
  background-color: #1f2937;
  color: #f9fafb;
  padding: 1rem;
  border-radius: 6px;
  margin: 1rem 0;
  overflow-x: auto;
}

.tiptap code {
  background-color: #f3f4f6;
  color: #ef4444;
  padding: 0.2rem 0.4rem;
  border-radius: 3px;
  font-size: 0.9em;
  font-family: 'Courier New', monospace;
}

.tiptap pre code {
  background-color: transparent;
  color: inherit;
  padding: 0;
}

/* 水平分割线样式 */
.tiptap hr {
  border: none;
  border-top: 2px solid #e5e7eb;
  margin: 2rem 0;
}

6.3 测试工具栏

保存文件后,你应该能看到:

  • 一个漂亮的工具栏
  • 点击按钮可以格式化文字
  • 激活的按钮会高亮显示
  • 不可用的按钮会被禁用

测试步骤:

  1. 选中一些文字
  2. 点击 "B" 按钮,文字应该变粗
  3. 按钮应该高亮显示
  4. 再次点击,文字恢复正常

7. 基本配置选项

7.1 常用配置

const editor = useEditor({
  // 扩展配置
  extensions: [StarterKit],
  
  // 初始内容
  content: '<p>Hello World!</p>',
  
  // 是否可编辑
  editable: true,
  
  // 是否自动获取焦点
  autofocus: false,
  
  // 事件回调
  onUpdate: ({ editor }) => {
    console.log('内容已更新', editor.getHTML())
  },
  
  onCreate: ({ editor }) => {
    console.log('编辑器已创建')
  },
  
  onFocus: ({ editor }) => {
    console.log('编辑器获得焦点')
  },
  
  onBlur: ({ editor }) => {
    console.log('编辑器失去焦点')
  },
})

7.2 配置选项说明

选项 类型 默认值 说明
extensions Extension[] 必需 编辑器使用的扩展数组
content string | JSONContent '' 初始内容(HTML 或 JSON)
editable boolean true 是否可编辑
autofocus boolean | 'start' | 'end' false 自动获取焦点
onUpdate function - 内容更新时触发
onCreate function - 编辑器创建时触发
onFocus function - 获得焦点时触发
onBlur function - 失去焦点时触发

8. 完整源码

📄 src/Tiptap.tsx

import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import './Tiptap.css'

function Tiptap() {
  const editor = useEditor({
    extensions: [StarterKit],
    content: '<p>Hello World! 🌍</p>',
  })

  if (!editor) {
    return null
  }

  return (
    <div className="editor-container">
      <div className="toolbar">
        <button
          onClick={() => editor.chain().focus().toggleBold().run()}
          className={editor.isActive('bold') ? 'is-active' : ''}
        >
          <strong>B</strong>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleItalic().run()}
          className={editor.isActive('italic') ? 'is-active' : ''}
        >
          <em>I</em>
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleStrike().run()}
          className={editor.isActive('strike') ? 'is-active' : ''}
        >
          <s>S</s>
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
          className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
        >
          H1
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
          className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
        >
          H2
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().toggleBulletList().run()}
          className={editor.isActive('bulletList') ? 'is-active' : ''}
        >
          • 列表
        </button>
        
        <button
          onClick={() => editor.chain().focus().toggleOrderedList().run()}
          className={editor.isActive('orderedList') ? 'is-active' : ''}
        >
          1. 列表
        </button>
        
        <div className="divider"></div>
        
        <button
          onClick={() => editor.chain().focus().undo().run()}
          disabled={!editor.can().undo()}
        >
          ↶ 撤销
        </button>
        
        <button
          onClick={() => editor.chain().focus().redo().run()}
          disabled={!editor.can().redo()}
        >
          ↷ 重做
        </button>
      </div>
      
      <EditorContent editor={editor} />
    </div>
  )
}

export default Tiptap

📄 src/App.tsx

import Tiptap from './Tiptap'
import './App.css'

function App() {
  return (
    <div className="app">
      <h1>我的 Tiptap 编辑器</h1>
      <Tiptap />
    </div>
  )
}

export default App

9. 本章总结

在本章中,我们学习了:

✅ 环境准备

  • 检查 Node.js 版本
  • 安装包管理器(pnpm)
  • 创建 Vite 项目

✅ 安装 Tiptap

  • 安装核心包(@tiptap/react、@tiptap/pm、@tiptap/starter-kit)
  • 理解包的作用和大小

✅ 创建编辑器

  • 使用 useEditor Hook
  • 渲染 EditorContent 组件
  • 添加基础样式

✅ StarterKit

  • 包含 15+ 个常用扩展
  • 自定义配置
  • 禁用特定扩展

✅ 添加工具栏

  • Commands 链式调用
  • 检查激活状态
  • 检查命令可用性
  • 添加工具栏样式

✅ 基本配置

  • 常用配置选项
  • 事件回调

🎯 关键知识点

1. useEditor Hook

const editor = useEditor({
  extensions: [StarterKit],
  content: '<p>Hello World!</p>',
})

2. Commands 链式调用

editor.chain().focus().toggleBold().run()

3. 检查状态

editor.isActive('bold')
editor.can().undo()

10. 下一步

现在你已经创建了第一个 Tiptap 编辑器!接下来我们将:

第 3 章:Tiptap 与 React 集成

  • 深入理解 useEditor Hook
  • 使用 EditorProvider
  • 实现自动保存
  • 处理 Next.js SSR

第 4 章:框架集成 - Vue/其他

  • Vue 集成
  • Angular 集成
  • Vanilla JavaScript

准备好继续学习了吗?🚀


11. 练习题

练习 1:添加更多按钮

在工具栏中添加以下按钮:

  • H3 标题
  • 引用块(Blockquote)
  • 代码块(CodeBlock)
  • 水平分割线(HorizontalRule)
💡 提示
<button
  onClick={() => editor.chain().focus().toggleBlockquote().run()}
  className={editor.isActive('blockquote') ? 'is-active' : ''}
>
  引用
</button>

练习 2:添加字符计数

在编辑器下方显示当前字符数。

💡 提示
const characterCount = editor.state.doc.textContent.length

<div className="character-count">
  {characterCount} 字符
</div>

练习 3:实现只读模式

添加一个切换按钮,可以切换编辑器的可编辑状态。

💡 提示
const [editable, setEditable] = useState(true)

useEffect(() => {
  if (editor) {
    editor.setEditable(editable)
  }
}, [editor, editable])

12. 常见问题

Q1: 为什么编辑器是 null?

A: 首次渲染时,编辑器还未初始化。解决方案:

if (!editor) return null

Q2: 如何获取编辑器内容?

A: 使用 getHTML()getJSON() 方法:

const html = editor.getHTML()
const json = editor.getJSON()

Q3: 如何设置编辑器内容?

A: 使用 setContent() 方法:

editor.commands.setContent('<p>新内容</p>')

Q4: 快捷键不工作?

A: 确保编辑器有焦点:

editor.chain().focus().toggleBold().run()

13. 扩展阅读


【JavaScript面试题-算法与数据结构】手写一个 LRU(最近最少使用)缓存类,支持 `get` 和 `put` 操作,要求时间复杂度 O(1)

一、数据结构设计

为了实现 get 和 put 操作的时间复杂度 O(1) ,我们组合了两种数据结构:

  1. 哈希表Map 或普通对象)

    • 存储键到双向链表节点的映射。
    • 作用:通过 key 直接定位到节点,实现 O(1) 的查找。
  2. 双向链表Node 类实现)

    • 维护所有节点的 使用顺序:链表头部(head 之后)是最近使用的节点,链表尾部(tail 之前)是最久未使用的节点。
    • 作用:在 O(1) 时间内完成节点的 移动到头部删除尾部 等操作。

此外,使用两个 哨兵节点(伪头 head 和伪尾 tail),避免处理链表为空或只有一个节点时的边界条件,使插入和删除操作更简洁。

以下是 JavaScript 手写的 LRU 缓存类,使用哈希表 + 双向链表实现,确保 get 和 put 操作时间复杂度为 O(1):

javascript

class LRUCache {
    /**
     * @param {number} capacity 缓存容量
     */
    constructor(capacity) {
        this.capacity = capacity;
        this.cache = new Map(); // 键 → 节点
        // 创建哨兵头尾节点,简化边界处理
        this.head = new Node(0, 0);
        this.tail = new Node(0, 0);
        this.head.next = this.tail;
        this.tail.prev = this.head;
    }

    /**
     * 获取键对应的值,并将该节点移动到链表头部(最近使用)
     * @param {number} key
     * @return {number}
     */
    get(key) {
        if (!this.cache.has(key)) {
            return -1;
        }
        const node = this.cache.get(key);
        this._moveToHead(node);
        return node.value;
    }

    /**
     * 插入或更新键值对,并将节点置于头部(最近使用)
     * 若容量超限,删除尾部节点(最久未使用)
     * @param {number} key
     * @param {number} value
     * @return {void}
     */
    put(key, value) {
        if (this.cache.has(key)) {
            // 已存在:更新值并移到头部
            const node = this.cache.get(key);
            node.value = value;
            this._moveToHead(node);
        } else {
            // 不存在:新建节点
            if (this.cache.size === this.capacity) {
                // 容量已满,删除尾部节点(最久未使用)
                const tailNode = this.tail.prev;
                this._removeNode(tailNode);
                this.cache.delete(tailNode.key);
            }
            const newNode = new Node(key, value);
            this.cache.set(key, newNode);
            this._addToHead(newNode);
        }
    }

    /**
     * 将节点从原位置移除,并添加到头部
     * @param {Node} node
     */
    _moveToHead(node) {
        this._removeNode(node);
        this._addToHead(node);
    }

    /**
     * 从链表中移除节点
     * @param {Node} node
     */
    _removeNode(node) {
        const prev = node.prev;
        const next = node.next;
        prev.next = next;
        next.prev = prev;
    }

    /**
     * 将节点插入到哨兵头节点之后(头部)
     * @param {Node} node
     */
    _addToHead(node) {
        node.prev = this.head;
        node.next = this.head.next;
        this.head.next.prev = node;
        this.head.next = node;
    }
}

/**
 * 双向链表节点
 */
class Node {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.prev = null;
        this.next = null;
    }
}

使用示例

javascript

const lru = new LRUCache(2);
lru.put(1, 1);      // 缓存: {1=1}
lru.put(2, 2);      // 缓存: {1=1, 2=2}
console.log(lru.get(1)); // 返回 1,并移动 1 到头部 → 缓存顺序: 2,1
lru.put(3, 3);      // 容量已满,删除尾部 2 → 缓存: {1=1, 3=3}
console.log(lru.get(2)); // 返回 -1 (未找到)
lru.put(4, 4);      // 容量已满,删除尾部 1 → 缓存: {3=3, 4=4}
console.log(lru.get(1)); // 返回 -1
console.log(lru.get(3)); // 返回 3
console.log(lru.get(4)); // 返回 4

复杂度说明

  • get: 哈希表查找 O(1) + 链表移动 O(1) → 总体 O(1)
  • put: 哈希表插入/更新 O(1) + 可能删除尾部 O(1) + 链表操作 O(1) → 总体 O(1)

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

开源一年,我的 AI 全栈项目 AI 协同编辑器终于有 1.1 k star了 😍😍😍

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

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

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

image.png

📖 简介

DocFlow 是一款面向团队协作的块级文档编辑器。它融合了 Notion 的灵活性与飞书的协作能力,通过块级内容架构、实时协同编辑和 AI 辅助功能,帮助团队高效完成文档创作与知识管理。

我们希望通过技术手段减少协作摩擦,让文档编辑更接近团队的真实工作流。无论是产品规划文档、技术方案设计,还是会议记录整理,DocFlow 都能提供流畅的创作体验。

✨ 核心特性

DocFlow 参考了 Notion 与飞书的设计理念,将内容以块为单位进行组织。每个块都是独立的编辑单元,可以灵活组合与调整,同时支持实时协作与 AI 辅助。

  • 🧱 块级编辑器:支持文本、标题、列表、代码块、表格、图片、视频等 20+ 种内容类型,通过拖拽即可调整块级元素的顺序与层级关系。

  • ⚡ 实时协作:基于 Yjs CRDT 算法实现多人同步编辑,自动处理编辑冲突。支持实时光标跟踪、成员在线状态与历史版本回溯。

  • 🤖 AI 功能:内置 AI 助手,支持头脑风暴、内容润色、文档续写与智能问答。可根据上下文生成结构化内容建议。

技术选型

DocFlow 采用全栈 TypeScript 架构,前端基于 Next.js 构建,后端使用 NestJS 框架。通过统一的类型系统和现代化的工程实践,保证了代码质量与开发效率。

🎨 前端架构 (Client-side)

Next.js

项目基于 Next.js App Router 架构,利用 React Server Components 优化首屏渲染性能。通过 Server Actions 实现前后端通信,确保类型安全的同时简化了数据流转。

Tiptap

编辑器核心采用 Tiptap 框架,基于 ProseMirror 构建。通过扩展机制实现了丰富的块级编辑能力,支持自定义节点与快捷命令,为用户提供接近 Notion 的编辑体验。

Yjs

协作功能基于 Yjs CRDT 算法实现,能够自动处理多人编辑时的冲突,保证数据最终一致性。配合 Awareness 模块,实现了实时光标追踪与在线状态同步。

⚙️ 后端架构 (Server-side)

NestJS & Prisma

后端使用 NestJS 模块化框架,通过依赖注入实现业务逻辑解耦。Prisma ORM 提供类型安全的数据访问层,支持高效的数据库查询与迁移管理。

Hocuspocus

Hocuspocus 作为 Yjs 的 WebSocket 服务端,负责协调文档协作会话,处理客户端连接与数据同步。通过拦截器机制实现权限控制与数据持久化。

Prometheus & Grafana

集成 Prometheus 进行指标采集,通过 Grafana 可视化展示系统运行状态。监控包括 API 响应时间、数据库查询性能、WebSocket 连接数等核心指标。

20260203091658

Grafana 监控面板实时展示系统各项性能指标,包括请求量、响应时间、错误率等关键数据,帮助快速定位性能瓶颈。

ELK Stack (Elasticsearch & Kibana)

使用 Elasticsearch 存储和检索日志数据,Kibana 提供日志分析与可视化能力。支持全文搜索、日志聚合与异常检测,便于问题排查与系统审计。

日志分析系统

Kibana 日志分析界面,支持按时间、日志级别、服务模块等维度查询和过滤日志,提供结构化的问题排查路径。

MinIO & RabbitMQ

MinIO 提供对象存储服务,用于存储用户上传的图片、视频等文件。RabbitMQ 作为消息队列,处理异步任务如图片压缩、邮件发送等,避免阻塞主业务流程。

功能介绍

DocFlow 将 AI 能力集成到编辑器中,通过理解文档上下文来辅助内容创作。AI 不是简单的文本生成工具,而是能够理解语义、提供决策建议的智能助手。

AI 头脑风暴

当你有一个初步想法但不知如何展开时,AI 头脑风暴可以帮助拓展思路。输入核心概念后,AI 会从不同角度生成 3-6 个结构化方案,每个方案都包含具体的实施思路。

AI 头脑风暴输入界面

在编辑器中输入头脑风暴主题,AI 会基于输入内容理解你的需求场景。

AI 头脑风暴结果展示

AI 生成的多个方案以卡片形式展示,每个方案都有清晰的标题和详细说明。你可以选择任意方案插入到文档中,或者继续优化调整。

这不只是简单的内容生成,AI 会根据上下文理解你的意图。无论是产品功能设计、内容分类规划,还是业务流程优化,AI 都能提供可行的思路参考,帮助快速决策。

AI 文本润色

AI 文本润色功能

选中需要优化的文本段落,AI 会分析文本结构与表达方式,提供更清晰、更专业的改写建议。支持调整语气风格,如正式、简洁、友好等。

AI 续写

AI 续写功能会根据前文内容自然延续写作。当前文内容较长时,系统通过 RAG (检索增强生成) 技术,从文档中检索相关段落,确保续写内容与上下文保持逻辑一致,避免偏离主题。

AI 续写功能演示

AI 续写时会参考前文的写作风格、用词习惯和逻辑结构,生成连贯自然的后续内容。你可以继续编辑生成的文本,或者重新生成。

AI 聊天

目前 AI 聊天功能作为独立页面存在,后续会集成到编辑器侧边栏,与文档内容深度关联。未来计划实现 Agent 模式,类似 Cursor 那样能够自动编辑文档内容。

7a8ba58a4ab3b592bb7fae1b45634648

协同编辑

多人协同编辑

多人同时编辑时,每个用户都有独立的光标颜色标识。文档修改实时同步,冲突自动合并。右侧显示当前在线成员列表与他们的编辑位置。

未来计划

DocFlow 将持续优化协作体验与 AI 能力,同时加强工程化建设,提升系统可扩展性。

🏗️ 工程化体系深度重构

  • 迈向 Monorepo 架构:计划基于 pnpm workspaces 和 Turborepo 将项目重构为 Monorepo。前后端代码分离,共享类型定义与工具函数,提升代码复用率与构建效率。

  • 组件库与插件生态开放:将 Tiptap 自定义扩展(如代码沙箱、交互式图表等)提取为独立 npm 包,开放给社区使用。同时建立插件开发规范,支持第三方开发者扩展编辑器能力。

🎙️ 多维协同体验升级

  • 集成 LiveKit 实时音视频:在文档协作场景中引入实时音视频通话。团队成员可以边看文档边讨论,提升复杂决策场景下的沟通效率。

LiveKit 集成方案

  • 实时群聊系统:在文档侧边栏集成实时聊天功能,支持针对文档内容发起讨论。消息可以关联到具体的文档块,形成完整的协作反馈闭环。

🤖 智能内核的跨越式进化

  • 基于 RAG 的私有知识库:引入 RAG (Retrieval-Augmented Generation) 技术,让 AI 能够检索用户的历史文档。AI 回答问题时会参考团队沉淀的知识资产,提供更精准的决策支持。

  • 从 Copilot 迈向 Agent:探索 AI Agent 在文档场景的应用。未来 AI 将能够自主执行任务,例如从会议纪要中提取待办事项,自动同步到第三方工具,实现从辅助创作到自动化办公的升级。

🚀 快速开始

环境要求

  • Node.js >= 24
  • pnpm >= 10.28.2

本地开发

  1. 克隆仓库
git clone https://github.com/xun082/DocFlow.git
cd DocFlow
  1. 安装依赖
pnpm install
  1. 启动开发服务器
pnpm dev
  1. 打开浏览器访问
http://localhost:3000

🐳 Docker 部署

方式一:使用 Docker Compose(推荐)

# 使用预构建镜像
docker-compose up -d

# 访问应用
http://localhost:3000

方式二:手动构建

  1. 构建镜像
docker build -t docflow:latest .
  1. 运行容器
docker run -d \
  --name docflow \
  -p 3000:3000 \
  -e NODE_ENV=production \
  docflow:latest
  1. 访问应用
http://localhost:3000

健康检查

容器内置健康检查端点:

curl http://localhost:3000/api/health

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

在提交代码前,请确保:

  • 运行 pnpm type-check 通过类型检查

  • 运行 pnpm lint 通过代码检查

  • 运行 pnpm format 格式化代码

  • 遵循项目的代码规范和提交规范

详见 CONTRIBUTING.md

📬 联系方式

infer,TS 类型系统的手术刀

在 TypeScript 的高级玩法里,infer 经常让初学者感到头大。它长得像关键字,用起来像正则表达式的“捕获组”,还必须寄生在 extends 条件语句里。

要把这东西彻底搞清楚,我们得先拆解它的核心逻辑,再看看它在实战中到底解决了什么问题。

一、 核心概念:infer 到底是什么?

简单来说,infer 就是 “类型系统里的临时变量”

在常规的泛型中,是你告诉 TypeScript 具体的类型;而在使用 infer 的场景下,是 TypeScript 自动推断出某个位置的类型,并把它存到一个变量里供你后续使用。

语法规则:

  1. 只能在 extends 条件类型的“真”分支中使用。
  2. 配合模式匹配使用。 你给出一个“模版”(比如函数结构、数组结构),让 TS 去匹配并提取其中的零件。

二、 语义纠偏:extends 的“变脸”

很多人的困惑源于 extends 这个词。在 Class 里它是“继承”,但在类型定义(尤其是配合 infer)时,它其实是 “模式匹配(Pattern Matching)”

  • Class 中的 extends:我是你的后代,我继承你的基因。
  • 类型中的 extends:我能不能塞进你这个形状的盒子里?

当你在写 T extends (infer R)[] ? R : never 时,你实际上是在对 TS 说:

“帮我看看 T 是不是一个数组。如果是,顺便把数组里装的那个东西的类型抠出来,起个临时名字叫 R。如果匹配成功,我就要这个 R。”


三、 实战场景:它能解决什么痛苦?

如果没有 infer,类型系统就是静态的、死板的。有了它,类型系统就具备了“解剖”和“重组”的能力。

1. 经典的“解包” (Unpacking)

这是最常见的用途。比如从 PromiseArrayMap 中提取内部类型。

// 提取 Promise 内部的类型
type Unbox<T> = T extends Promise<infer U> ? U : T;

type Str = Unbox<Promise<string>>; // 得到 string

2. 函数全家桶 (Function Extraction)

你可以轻松拿到一个函数的返回类型、参数类型,甚至是构造函数的参数。

// 提取函数第一个参数的类型
type FirstParam<T> = T extends (arg1: infer P, ...args: any[]) => any ? P : never;

function saveUser(id: number, name: string) {}
type IDType = FirstParam<typeof saveUser>; // number

3. 字符串模板的“手术刀”

这是 TS 4.1 之后的黑科技。你可以用它来拆分字符串,做一些像“驼峰转下划线”之类的类型转换。

type GetExtension<T> = T extends `${string}.${infer Ext}` ? Ext : never;

type FileExt = GetExtension<"config.json">; // "json"

四、 总结:什么时候该用它?

你不需要在每一处代码都写 infer,但在以下场景,它是无可替代的神器:

  • 处理第三方库:当你拿不到某个库内部定义的具体接口,但你能拿到它的函数或实例时,可以用 infer 反向推导出它的类型。
  • 减少重复定义:不想为了一个返回值再去手动写一遍复杂的 interface
  • 编写通用工具库:它是构建自动化、高适配性类型系统的基石。

虽然 infer 很好用,但它会显著增加类型的理解成本。对于团队协作项目,建议只在底层工具类型(Utils)中使用它,业务代码中还是尽量保持类型声明的直观和显式。

🪝 别再重复造轮子了!教你偷懒:在 React 自定义 Hook

前言

React 组件时,你是不是总感觉有些逻辑似曾相识?

  • 比如,每次都要写一遍判断组件是否挂载的逻辑
  • 又比如,监听元素 hover 状态的代码复制了一次又一次
  • 再比如,组件挂载和卸载时的操作也总是那几行

React官方给出了许多hook供我们使用,比如我们常见的useEffectuseState等等,但光靠这些是不够的,今天分享一些自定义的hook,方便又高效!

🎯 场景一:我只是想知道组件 “活” 没活

你有没有遇到过这种情况:组件里的setTimeout还没跑完,组件就已经被卸载了,控制台立刻给你甩一个警告,仿佛在说 “你操作了一个不存在的组件”

别慌,咱们用useMountedState这个自定义 Hook 就能完美解决。

// useMountedState.js
import { useRef, useEffect } from 'react'

export default function useMountedState() {
    const mounted = useRef(false);
    const get = () => mounted.current;
    useEffect(() => {
        mounted.current = true;
        return () => {
            mounted.current = false;
        }
    }, [])
    return get;
}

在组件里用起来就像给组件装了个 “生命检测仪”

// App.jsx
import React, { useState, useEffect } from 'react'
import useMountedState from './hooks/useMountedState'

export default function App() {
    const isMounted = useMountedState();
    const [num, setNum] = useState(0);
    useEffect(() => {
        setTimeout(() => {
            // 先检查组件是否还活着,再更新状态
            if (isMounted()) {
                setNum(1);
            }
        }, 1000);
    }, []);

    return (
        <div>
            {isMounted() ? '组件挂载完成 🎉' : '组件还在编译 🛠️'}
        </div>
    )
}

刚打开浏览器(显示还在编译):

image.png

过几秒(挂载完成):

image.png

有了它,你再也不用担心在异步操作里更新一个已经 “去世” 的组件了。

🎬 场景二:组件的 “登场” 与 “谢幕” 要仪式感

组件挂载卸载时,我们经常需要做一些初始化和清理工作。

  • 比如页面埋点、订阅事件、定时器清理等
  • 直接用useEffect写虽然也行,但每次都要写return总觉得有点麻烦

这时候useLifecycles就派上用场了,它把组件的 “生命周期” 打包成了一个简单的接口

// useLifecycles.js
import { useEffect } from 'react'

export default function useLifecycles(onMount, onUnmount) {
    useEffect(() => {
        if (onMount) {
            onMount();
        }
       return () => {
            if (onUnmount) {
                onUnmount();
            }
        }
    }, []);
}

用起来就像给组件安排了 “入场” 和 “退场” 的节目单

// App2.jsx
import React, { useState } from 'react'
import useLifecycles from './hooks/useLifecycles';

const Child = () => {
    useLifecycles(
        () => {
            console.log('child组件挂载🎬');
        },
        () => {
            console.log('child组件卸载👋');   
        }
    )
    return <h1>child组件</h1>
}

export default function App2() {
    const [show, setShow] = useState(true);
    return (
        <div>
            <h1 onClick={() => setShow(!show)}>App2</h1>
            {
                show && <Child></Child>
            }
        </div>
    )
}

刚打开浏览器一定会打印child组件挂载🎬

image.png

当点击App2时,child组件消失 (卸载),打印child组件卸载 👋

image.png

✋ 场景三:元素 hover 状态的 “小雷达”

实现元素hover效果是前端的家常便饭,传统写法需要给元素绑定onMouseEnteronMouseLeave事件。

  • 逻辑不复杂,但写多了也烦
  • 咱们可以用useHover把这个逻辑封装成一个 Hook
// useHover.jsx
import { useState, cloneElement } from 'react'

export default function useHover(element) {
    const [state, setState] = useState(false);
    const onMouseEnter = (originalOnMouseEnter) => {
        return (event) => {
            originalOnMouseEnter?.(event);
            setState(true);
        }
    };
    const onMouseLeave = (originalOnMouseLeave) => {
        return (event) => {
            originalOnMouseLeave?.(event);
            setState(false);
        }
    };
    if (typeof element === 'function') {
        element = element(state);
    }
    const el = cloneElement(element, {
        onMouseEnter: onMouseEnter(element.props.onMouseEnter),
        onMouseLeave: onMouseLeave(element.props.onMouseLeave),
    })
    return [el, state];
}

在组件里使用时,就像给元素装了个 “小雷达”

// App3.jsx
import useHover from './hooks/useHover.jsx';

export default function App3() {
    const element = (hovered) => {
        return <div>
            Hover me! {hovered && 'Thanks!'}
        </div>
    }
    const [hoverable, hovered] = useHover(element);
    return (
        <div>
            {hoverable}
            {hovered ? 'yes ✅' : 'no ❌'}
        </div>
    )
}

鼠标不在Hover me!上面的时候(显示no ❌):

image.png

当鼠标🖱️移动到Hover me!上面的时候(显示yes ✅):

image.png

鼠标悬停时,元素会显示 “Thanks!”,下方也会同步显示状态,交互体验直接拉满!

🚀 用别人写好的库

大家应该发现了,上面的组件都是我自己手搓的,其实已经有很多人写好了,我们只需下载然后就可以使用了。我给大家推荐一个:

地址: www.npmjs.com/package/rea…

下载:npm i react-use

里面有许多已经封装好了的hook组件,包括上面介绍的,只需引入即可:

import { useMountedState } from 'react-use';
import { useHover } from 'react-use';
import { useLifecycles } from 'react-use';

结语

自定义 Hook 就像 React 世界里的 “乐高积木”,把零散的逻辑拼成一个个可复用的模块。

  • 它不是什么高大上的魔法,就是把你本来要重复写的代码打包了一下
  • 不仅能让你的代码更干净,还能让你开发时少掉几根头发

下次再遇到重复逻辑时,别再 cv 了,动手写个自定义 Hook 吧!毕竟,优秀的程序员都是会 “偷懒” 的艺术家

🎯 DOM 事件:onclick VS addEventListener('click')区别

🎯 DOM 事件:onclick vs addEventListener('click') 区别

特性 .on 事件(如 onclick addEventListener('click')
绑定数量 只能绑 1 个(后面覆盖前面) 可以绑 多个(按顺序执行)
移除方式 el.onclick = null 需要 removeEventListener,且必须传同一个函数引用
事件阶段 只能在 冒泡阶段 触发 可以选择 捕获 / 冒泡 阶段(第三个参数)
标准级别 DOM 0 级(老写法) DOM 2 级(现代标准推荐)

区别详解

绑定数量

onclick:只能绑 1 个,后面覆盖前面

const btn = document.getElementById('btn');

btn.onclick = function() {
  console.log('第一次点击'); // 不会执行!被覆盖了
};

btn.onclick = function() {
  console.log('第二次点击'); // 只有这个会执行
};

 addEventListener:多个都执行

const btn = document.getElementById('btn');

function fn1() {
  console.log('第一次点击'); // 会执行
}

function fn2() {
  console.log('第二次点击'); // 也会执行!按顺序来
}

btn.addEventListener('click', fn1);
btn.addEventListener('click', fn2);

移除事件

onclick 移除:直接设为 null

btn.onclick = function() { alert('点击了'); };
// 移除
btn.onclick = null; 

addEventListener 移除:必须传同一个函数

⚠️ 注意:如果用匿名函数,是无法移除的!

//✅ 正确写法(用命名函数)
function myClick() {
  console.log('点击了');
}

btn.addEventListener('click', myClick);
// 移除(必须传同一个函数名)
btn.removeEventListener('click', myClick);

//❌ 错误写法(无法移除)
btn.addEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

// 没用!因为这是两个不同的函数引用
btn.removeEventListener('click', function() {
  console.log('匿名函数,删不掉我');
});

事件阶段

// 第三个参数:
// true → 在捕获阶段触发
// false(默认)→ 在冒泡阶段触发

el.addEventListener('click', fn, true); // 捕获阶段
el.addEventListener('click', fn, false); // 冒泡阶段
简单理解事件流:

假设 HTML 是 body > div > button

  1. 捕获阶段:从外到内(body → div → button
  2. 目标阶段:到达 button
  3. 冒泡阶段:从内到外(button → div → body

onclick 只能在冒泡阶段触发,而 addEventListener 可以自由选择。

所以我用哪个呢?

  1. 90% 的场景:用 addEventListener

    • 更灵活,能绑多个事件
    • 现代标准,功能强大
    • 团队协作推荐
    • 不知道用啥就用它
  2. 简单快速测试 / 临时写个小功能:可以用 onclick

    • 代码少,写得快
    • 移除简单(直接 null

❌