阅读视图

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

第11章 LangChain

LangChain 是一个用于开发基于大语言模型(LLM)应用程序的开源框架,它通过提供模块化的抽象组件和链式调用工具,将 LLM 与外部数据源(如文档、数据库)和计算工具(如搜索引擎、代码解释器)智能连接,从而构建出具备记忆、推理和行动能力的增强型 AI 应用,典型场景包括智能问答、内容生成和智能体(Agent)系统。

LangChain官方文档:docs.langchain.com/。网址的组成逻辑和Ne…

LangChain支持Python和TypeScript两种编程语言,如图11-1所示。

image-20251219041533100

图11-1 LangChain开源代理框架-语言选项

LangChain开源代理框架有3种选择方案:

(1)LangChain:一些普通对话,音频识别、文字生成,图片生成等等与AIGC相关的,用这选择方案就够了。

(2)LangGraph:想做工作流,类似Dify,Coze,那么就需要使用该方案。

(3)Deep Agents:想做一些大型的AI相关高级应用,就需要使用该方案,Deep Agents是深度集成的意思。

LangChain 作为基础框架,适合构建常规的AIGC应用(如对话、文生图);LangGraph 专注于通过有状态、可循环的图结构来编排复杂、多步骤的智能体工作流,是开发类Dify/Coze平台或自动化业务流程的核心选择;而 Deep Agents 则代表了一种更深度集成、能处理高复杂度任务与自主决策的高级智能体架构,常用于需要多智能体协作或模拟人类工作流的大型企业级AI应用。

我们这里学习的话,使用第一个LangChain就完全够用。LangChain下载使用说明如图11-2所示。我们点击如图11-1所示的第一个选项后,会跳转到如图11-2所示的界面,需要点击左侧边栏的install选项。官方文档有对应的使用说明。

image-20251219042243761

图11-2 LangChain下载使用说明

## 11.1 初始化项目

接下来,我们要开始初始化这次的项目。会沿用第10章 SSE魔改的代码,在该基础上,需要补充以下安装步骤:

(1)@langchain/core:LangChain 的核心基础库,包含链、提示模板、检索器等核心抽象。

(2)@langchain/deepseek: LangChain 为DeepSeek 模型专门提供的集成包,让我们能在 LangChain 框架中直接调用 DeepSeek 的 API。安装规则是@langchain/所需AI大模型,例如@langchain/openai。

(3)langchain:LangChain 的主包,提供了高级、易于使用的接口来组合和使用 LLM。

安装LangChain之后,我们需要一个AI大模型来支持,在这次示例中,选择DeepSeek,因为它的API非常便宜。

// 终端执行命令
npm install @langchain/core @langchain/deepseek langchain

安装之后的package.json文件如下所示。

{
  "type": "module",
  "dependencies": {
    "@langchain/core": "^1.1.6",
    "@langchain/deepseek": "^1.0.3",
    "@types/cors": "^2.8.19",
    "@types/express": "^5.0.6",
    "cors": "^2.8.5",
    "express": "^5.2.1",
    "langchain": "^1.2.1"
  }
}

由于我们在7.1小节已经升级过Node.js版本,因此可以直接用Node.js去运行ts后缀文件,满足Node.js版本大于23的都可以这么做,如果无法运行ts后缀文件,需要检查一下Node.js版本或者采用ts-node。

接下来到index.ts文件中初始化后端服务。

// index.ts
import express from 'express'
import cors from 'cors'

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => { })

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

11.2 接入大模型

接着接入我们的AI大模型DeepSeek,还是在index.ts文件中。

在这里引入了一个key.ts文件,该文件存放着DeepSeek API的Key。

import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
const deepseek = new ChatDeepSeek({
    apiKey: key,
    model: 'deepseek-chat',
    temperature: 1.3,
    maxTokens: 1000, //500-600个汉字
    topP: 1, //设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
    frequencyPenalty: 0,//防复读机诉 AI:"你别老重复同一个词!"-2   2
    presencePenalty: 0,//鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

获取DeepSeek的key,如下4步骤:

(1)打开DeepSeek的API开放平台:platform.deepseek.com/

(2)微信扫码登录,去实名认证。

(3)点击左侧边栏的用量信息选择去充值选项,有在线充值和对公汇款两个选项,选择在线充值,自定义输入1块钱(够用了),然后自己选择支付宝或者微信支付去付款。

(4)付款成功后,点击左侧边栏的API keys选项,创建API key,随便输入一个名称(中英文都可以),然后会弹出key值,此时复制key值再关闭弹窗,因为当你关闭后,就再也拿不到这个key值了。忘记就只能重新再建一个,DeepSeek会提醒你的,如图11-3所示。

友情提示:保护好你的key值,别暴露在公网中,在开源项目上传GitHub中,可以让git忽略key.ts文件。或者如果你的DeepSeek API就只充了1块钱,然后用得剩几毛钱,并且以后都不怎么打算充,那想不想保护key值,就看你心情了。

image.png

图11-3 创建API key注意事项

通过以上步骤获取到DeepSeek的key值后,在项目创建key.ts文件,创建常量key,填入你的key值并导出。

export const key = '你DeepSeek的key值'

回到index.ts文件,接入DeepSeek大模型之后,ChatDeepSeek有一个model字段,这是用于选择我们模型的。已有的模型类型需要从DeepSeek官方文档中获取:模型 & 价格 | DeepSeek API Docs

// DeepSeek字段
apiKey: key,
model: 'deepseek-chat',
temperature: 1.3,
maxTokens: 1000, // 500-600个汉字
topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
frequencyPenalty: 0,// 防复读机诉 AI:"你别老重复同一个词!"-2   2
presencePenalty: 0,// 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2

目前可选的模型有deepseek-chat和deepseek-reasoner。DeepSeek官网价格计算是以百万Token为单位,其中1 个英文字符 ≈ 0.3 个 token;1 个中文字符 ≈ 0.6 个 token。只要大概知道很便宜就足够了。

image-20251219050542592

图11-4 DeepSeek模型选择

temperature字段是温度的含义,在DeepSeek官方文档中有直接给出对应的建议,我们的示例是打算用于对话,因此设置1.3就足够了,Temperature参数设置如图11-5所示。

从应用场景,我们可以理解为temperature字段大概是理性与感性的权衡度,逻辑性越强的场景,温度越低;越感性的场景温度越高。所有AI大模型都是类似的,从他们对应的官方文档去获取对应信息就可以了。

image-20251219050952588

图11-5 DeepSeek模型-Temperature参数设置

其余4个参数maxTokens、topP,frequencyPenalty和presencePenalty如下:

(1)maxTokens 直接决定了 AI 回复的最大长度,限制了单次响应的文本量;

(2)topP(核采样参数)通过控制候选词的概率分布来影响文本的创造性与稳定性——值越低则 AI 的选词越集中和可预测,输出趋于“死板”,值越高则选词范围越宽,输出越“放飞自我”并富有创意。

(3)而 frequencyPenalty 与 presencePenalty 则分别从词频和话题层面抑制重复:frequencyPenalty 正值会惩罚在当前回复中已经频繁出现的词语,促使用词更加多样;presencePenalty 正值则会惩罚在已生成的上下文中出现过的所有主题,鼓励 AI 主动切换到新的话题或角度,从而共同确保生成内容的多样性和连贯性,避免陷入单调或循环重复的表达。

这些值具体设置多少,则需要根据具体场景的经验以及自身的理解,推荐看我写的AI使用手册,开头有讲解到这一部分注意力机制:AI精准提问手册:从模糊需求到精准输出的核心技能(上)

11.3 AI对话

接下来需要从langchain引入createAgent方法,并使用我们设置好的deepseek实例对象。我们调用agent身上的invoke()方法,该方法更适合单次输出(一次性直接返回),即非流式返回。

通过createAgent方法除了可以设置接入的大模型,还可以通过systemPrompt字段去设置Prompt提示词。

通过LangChain代理的stream()方法调用DeepSeek模型处理用户请求:将客户端发送的req.body.message作为用户消息输入,并设置streamMode为 "messages" 来获取结构化的消息流响应;在等待代理完成流式生成后,将整个结果集作为JSON数据一次性返回给客户端。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/json')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.invoke({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  })
  res.json(result)
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

接下来回到index.html文件一下,我们需要设置客户端返回给后端的问题,也就是往req.body.message里塞一下咨询AI的问题。

<script>
    fetch('http://localhost:3000/api/chat', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res=>{
        const reader = res.body.getReader()
        const decoder = new TextDecoder()
        while (true) {
            const { done, value } = await reader.read()
            if (done) {
                break
            }
            const text = decoder.decode(value, { stream: true })
            console.log(text)
        }
    })
</script>

我们问了一个:“你是什么AI大模型”的问题,浏览器返回AI对话信息如图11-6所示。

image-20251219054656481

图11-6 DeepSeek模型-Temperature参数设置

到这为止,我们就正式打通了AI对话的环节。并且如果我们打开网络选项卡,可以发现AI对话返回的内容是post请求的。如果我们想改成流式输出也是post请求,在第10.3小节所学习的SSE设置post请求就可以用上了。

11.4 流式输出AI对话

如果我们想修改成流式输出对话的话,需要修改3个地方:

(1)后端设置的Content-Type类型改成事件流类型。

(2)agent不使用invoke()方法,该换专门的agent.stream()流输出方法,并调整对应参数。

(3)agent.stream()流输出方法返回迭代器,针对迭代器去调整输出形式。

import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})

agent.stream()方法有第二个参数,用于指定流式输出的数据格式和粒度,决定了从流中接收到的是原始令牌、结构化消息还是其他中间结果。agent.stream()方法第二个参数的选项如表11-1所示。我们选择messages就可以了。如果想要真正存粹的打字效果并且节约token,可以使用values选项。

表11-1 agent.stream()方法第二个参数的选项

流模式 返回的数据类型 典型用途 示例输出(逐块)
"messages" 完整的消息对象 需要处理结构化对话(如获取AI回复的完整消息) {"role": "assistant", "content": "你好"}
"values" 底层值(如原始token) 需要实现逐字打印效果或最低级控制 "你" "好"
"stream" 混合事件流 需要同时获取token和消息等多样信息 {"type": "token", "value": "你"}
const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "values" })

其次,由于agent.stream()方法的返回值类型是IterableReadableStream<StreamMessageOutput>,说明返回值就是一个迭代器。因此可以使用for await of语法糖来流式输出内容,不用手动的去调用迭代器的next()方法。

  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()

AI对话-流式输出如图11-7所示。会按顺序返回非常多的JSON格式数据,通过data字段下的kwargs的content可以看到AI返回内容以三两字的形式不断输出。并且在前端的接收流式输出,不会因post请求而出现问题。

image-20251219060557208

图11-7 AI对话-流式输出

流式输出AI对话的完整代码如下:

// index.ts
import express from 'express'
import cors from 'cors'
import { ChatDeepSeek } from '@langchain/deepseek'
import { key } from './key.ts'
import { createAgent } from 'langchain'
const deepseek = new ChatDeepSeek({
  apiKey: key,
  model: 'deepseek-chat',
  temperature: 1.3,
  maxTokens: 1000, // 500-600个汉字
  topP: 1, // 设得越小,AI 说话越"死板";设得越大,AI 说话越"放飞自我"
  frequencyPenalty: 0, // 防复读机诉 AI:"你别老重复同一个词!"-2   2
  presencePenalty: 0, // 鼓励换话题告诉 AI:"别老聊同一件事!" -2   2
})

const app = express()
app.use(cors())
app.use(express.json())

app.post('/api/chat', async (req, res) => {
  res.setHeader('Content-Type', 'application/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  const agent = createAgent({
    model: deepseek,
    systemPrompt: `你是一个聊天机器人,请根据用户的问题给出回答。`,
  })
  const result = await agent.stream({
    messages: [
      {
        role: 'user',
        content: req.body.message,
      }
    ]
  }, { streamMode: "messages" })
  for await (const chunk of result) {
    res.write(`data: ${JSON.stringify(chunk)}\n\n`)
  }
  res.end()
})

app.listen(3000, () => {
  console.log('Server is running on port 3000')
})
// index.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>
    fetch('http://localhost:3000/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ message: '请问你是什么AI大模型' })
    }).then(async res => {
      const reader = res.body.getReader()
      const decoder = new TextDecoder()
      while (true) {
        const { done, value } = await reader.read()
        if (done) {
          break
        }
        const text = decoder.decode(value, { stream: true })
        console.log(text)
      }
    })
  </script>
</body>

</html>

以上就是使用LangChain接入DeepSeek大模型并实现AI对话和基础提示词的案例。

前端模块化发展

JavaScript 模块化经历了从全局变量到 CommonJS、AMD/UMD、再到 ESM 的演进。ESM 是官方标准,具备静态分析、实时绑定特性,支持 Tree Shaking。

小程序上线半年我赚了多少钱?

1. 开发历程 时间过得真快,转眼间福猪记账小程序上线已经有半年时间了,从v1.0.0 版本做到了 v1.6.1 版本。今天整理了开发日志,真的不敢相信自己在工作之余能够把它做出来并上线。 2.用户积

一篇文章带你搞懂原型和原型链

我们在理解原型和原型链的时候,喜欢去看概念强行记住,当时以为记住了就懂了可等下次又会忘记,其实是还没有真正弄懂。想要弄懂,就要先知道到底难在哪,理清它们之间的关系,再去看概念性的东西,就轻松很多了。

推荐几个国外比较流行的UI库(上)

1、Tailwind CSS

现在写样式的时候,我基本已经离不开 Tailwind CSS 了。最开始接触它的时候,其实挺不适应的,感觉类名又多又杂,全写在标签上。但真正用顺了之后,反而不太想回到以前那种来回切 CSS 文件的方式。

我比较喜欢的是它处理响应式的方式,断点直接写在类名前面,逻辑非常直观。页面在不同尺寸下怎么变,一眼就能看出来。再加上配置文件可以统一管颜色、间距、字体这些东西,对我来说维护起来反而更轻松。

缺点当然也有,比如结构看起来不那么“干净”,但这个在我这里已经不算什么问题了。

下面我们来实现一个瀑布流

<div class="columns-3 ..."> 
    <img class="aspect-3/2 ..." src="/img/mountains-1.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-2.jpg" /> 
    <img class="aspect-square ..." src="/img/mountains-3.jpg" /> 
    <!-- ... -->
</div>

效果 image.png


2、Bootstrap

虽然 Bootstrap 已经很多年了,但说实话,在一些需求明确、节奏比较快的项目里,它依然很好用。栅格、常见组件基本都有,直接拼就能出页面,几乎不用想太多。

image.png


3、Foundation

Foundation 是一个开源的响应式前端框架,用于构建结构清晰、视觉一致的网页界面。它提供了完整的工具体系,包括响应式网格系统、设计模板,以及基于 HTML、CSS 和 SASS 的样式方案。同时,框架内置了按钮、导航、表单、排版等常见 UI 能力,并支持通过 JavaScript 扩展进一步增强交互功能。

Foundation 采用移动优先的设计理念,与 Bootstrap 类似,布局从小屏设备开始构建,再逐步扩展到更大的屏幕尺寸。这种方式使页面能够自然适配不同设备,无需额外处理复杂的适配逻辑,从而在手机、平板和桌面端之间保持一致且流畅的体验。

在布局层面,Foundation 提供了基于 Flexbox 的 12 列响应式网格系统。页面结构可以通过行与列的组合快速搭建,而网格系统会自动处理不同断点下的尺寸变化与内容堆叠,使整体布局保持简洁、直观且易于维护。

Foundation 的工具包体系也是其重要特性之一。框架内置了可直接使用的网页与邮件组件,使项目在启动阶段不必从零搭建基础结构。这种方式在多平台场景下有助于维持统一的视觉风格,并显著减少重复性工作。

在灵活性方面,Foundation 并未强制绑定特定的设计语言或样式规范。默认配置可根据项目需求进行调整或覆盖,从而在不受框架限制的前提下实现定制化界面设计。这种设计思路在效率与自由度之间取得了较好的平衡。

从整体特性来看,Foundation 对无障碍访问和移动优先设计的重视,使其在构建现代化、包容性网页体验时具有明显优势。模块化架构与 SASS 集成提升了组件定制的效率,也使复杂布局的原型构建更加顺畅。

相对而言,Foundation 的学习成本高于 Bootstrap 等更大众化的方案,对初学者存在一定门槛。此外,其社区规模和生态资源不及 Tailwind 和 Bootstrap 丰富,可直接复用的第三方资源相对有限。在功能完整度较高的同时,对于体量较小的项目而言,可能会引入不必要的复杂度。

特点

1.  **响应式**:先做好手机,再适配平板和电脑。
2.  **网格灵活**:12 列 Flexbox 布局,布局复杂也能处理好。
3.  **组件齐全**:带 JS 插件,交互也有现成的(弹窗、菜单等)。

适合谁:想快速搭复杂页面,有交互,又想用框架自带组件的人。

缺点:学习稍复杂,功能多了,小项目可能显得重。

下面我们来使用它的按钮样式

<!-- Anchors (links) --> 
<a href="about.html" class="button">Learn More</a> 
<a href="#features" class="button">View All Features</a> 

<!-- Buttons (actions) --> 
<button class="submit success button">Save</button> 
<button type="button" class="alert button">Delete</button>

效果

image.png


4、Bulma

Bulma 是那种一看就懂、上手很快的框架。类名语义清楚,布局基于 Flexbox,用起来很顺。

它不依赖 JavaScript 这一点,拿来配合任何技术栈都很方便。不过也正因为这样,一些交互相关的东西需要自己补,这点在用之前心里要有预期。

特点

1.  **响应式**:移动优先,Flexbox 网格布局。
2.  **轻量**:按钮、卡片、表单都有样式,但没有 JS。
3.  **易用**:学习成本低,改样式很方便。

适合谁:只需要快速搭页面、布局和样式固定、不需要框架自带交互的人。

缺点:没有交互组件,复杂行为要自己写。

下面我们来写一个简单的表单

<form class="box">
  <div class="field">
    <label class="label">Email</label>
    <div class="control">
      <input class="input" type="email" placeholder="e.g. alex@example.com" />
    </div>
  </div>

  <div class="field">
    <label class="label">Password</label>
    <div class="control">
      <input class="input" type="password" placeholder="********" />
    </div>
  </div>

  <button class="button is-primary">Sign in</button>
</form>

效果

image.png


react使用Ant Design

一、安装 官网:Ant Design npm yarn pnpm 二、main引用 引入Ant Design的reset.css文件 引入ConfigProvider全局配置,且包含所有组件 设置lo

JavaScript Date 语法要过时了!以后用这个替代!

1. 前言

作为一名前端开发工程师,你一定被 JavaScript 的日期处理折磨过。

这不是你的问题,是 JavaScript 自己的问题——它的 Date 功能真的很糟糕。

2. Date 的离谱行为

让我给你举几个例子,你就明白有多离谱了:

月份从 0 开始计数:

// 你以为这是 2026 年 1 月 1 日?
console.log(new Date(2026, 1, 1));
// 结果:2026 年 2 月 1 日!

// 因为月份是从 0 开始数的:0=1月,1=2月...
// 但年份和日期又是正常计数的

日期格式混乱到让人抓狂:

// 用斜杠分隔,加不加前导零都没问题
console.log(new Date("2026/01/02"));
// Fri Jan 02 2026 00:00:00 GMT+0800 (中国标准时间)

// 但如果用短横线分隔,同样的写法
console.log(new Date("2026-01-02"));
// Fri Jan 02 2026 08:00:00 GMT+0800 (中国标准时间)

// 时间居然不一样了!

// 如果用东半球标准时间,更离谱!一个是 1 月 2 日,一个是 1 月 1 日

两位数年份的迷惑行为:

console.log(new Date("49")); // 2049 年
console.log(new Date("99")); // 1999 年
console.log(new Date("100")); // 公元 100 年!

规则莫名其妙:33-99 代表 1900 年代,但 32-49 又代表 2000 年代,100 以上就真的是公元那一年了。

更致命的问题是 —— 日期居然可以被“改变”!

const today = new Date();
console.log(today.toDateString()); // Fri Jan 09 2026

// 我想算一下明天是几号
const addDay = (theDate) => {
  theDate.setDate(theDate.getDate() + 1);
  return theDate;
};

console.log(`明天是 ${addDay(today).toLocaleDateString()}。`);
// 明天是 2026/1/10。

console.log(`今天是 ${today.toLocaleDateString()}。`);
// 今天是 2026/1/10。

// 等等,今天怎么也变成明天了?!

当然这是可以解释的:

因为 today 就像一个地址,指向内存里的某个位置。当你把 today 传给函数时,函数拿到的也是这个地址。所以当函数修改日期时,原来的 today 也被改了。

但这种设计违反了一个基本常识:日期应该是固定的。“2026 年 1 月 10 日”就是“2026 年 1 月 10 日”,不应该因为你拿它做了个计算,它自己就变了。

所以 Date 真的很糟糕。实际上,它就是挂羊头卖狗肉,它叫做 Date,表示日期,实际上,它是时间。

在内部,Date 是以数值形式存储的,这就是我们熟悉的以 1000 毫秒为单位的时间戳。

时间当然包含日期,你可以从时间中推断出日期,但这多少有点恶心了。

Java 早在 1997 年就弃用了其 Date 类,而 JavaScript 的 Date 类仅仅在几年后就问世了;与此同时,我们却一直被这个烂摊子困扰着。

正如你目前所见,它在解析日期方面极其不稳定。它除了本地时间和格林威治标准时间 (GMT) 之外,对其他时区一无所知。而且,Date 类只支持公历。它完全不理解夏令时的概念。当然最糟糕的还是它的可变的,这直接让他偏离了时间的本质。

所有这些缺陷使得使用第三方库来解决这些问题变得异常普遍,其中一些库体积庞大,这种性能损耗已经对网络造成了切实可衡量的损害。

3. Temporal 才是未来

幸运的是,Date 即将彻底退出历史舞台。

当然这样说,还是有点夸张了。

实际上是它会一直存在,但如果可以避免,你最好不要再用它了。

因为我们会有一个完全取代 Date 的对象 —— Temporal。

部分同学可能对 Temporal 这个单词不太熟悉,实际上,它的意思就是“时间”,你可以理解为它是一个更专业的词汇:

与 Date 不同,Temporal 不是构造函数,它是一个命名空间对象——一个由静态属性和方法组成的普通对象,就像 Math 对象一样:

console.log(Temporal);
/* Result (expanded):
Temporal { … }
  Duration: function Duration()
  Instant: function Instant()
  Now: Temporal.Now { … }
  PlainDate: function PlainDate()
  PlainDateTime: function PlainDateTime()
  PlainMonthDay: function PlainMonthDay()
  PlainTime: function PlainTime()
  PlainYearMonth: function PlainYearMonth()
  ZonedDateTime: function ZonedDateTime()
  Symbol(Symbol.toStringTag): "Temporal"
*/

Temporal 包含的类和命名空间对象允许你计算两个时间点之间的持续时间、表示一个时间点(无论是否具有时区信息)、通过 Now 属性访问当前时间点等。

如果我们要获取当前时间:

console.log(Temporal.Now.plainDateISO());
/* Result (expanded):
Temporal.PlainDate 2025-12-31
  <prototype>: Object { … }
*/

该方法返回的是当前时区的今天日期。

Temporal 还能支持时区:

const date = Temporal.Now.plainDateISO();

// 指定这个日期在伦敦时区
console.log(date.toZonedDateTime("Europe/London"));

Temporal 还可以计算日期差:

const today = Temporal.Now.plainDateISO();
const jsShipped = Temporal.PlainDate.from("1995-12-04"); // JavaScript 发布日期
const difference = today.since(jsShipped, { largestUnit: "year" });

console.log(`JavaScript 已经存在了 ${difference.years}${difference.months} 个月零 ${difference.days} 天。`);

各种时间操作也会更加直观:

const today = Temporal.Now.plainDateISO();

// 加一天
console.log(today.add({ days: 1 }));

// 加一个月零一天,再减两年——可以链式操作
console.log(today.add({ months: 1, days: 1 }).subtract({ years: 2 }));

// 看,多清楚!

当然,更重要的是,日期不会被意外修改

const today = Temporal.Now.plainDateISO();

// 计算明天的日期
const tomorrow = today.add({ days: 1 });

console.log(`今天是 ${today}。`); // 2025-12-31
console.log(`明天是 ${tomorrow}。`); // 2026-01-01

// 今天还是今天,完美!

add 方法会返回一个新的日期对象,而不是修改原来的。就像你复印了一份日历,在复印件上写字,原件不会被弄脏。

4. 什么时候能用?

好消息:最新版的 Chrome 和 Firefox 已经支持了!

坏消息:它还在“实验阶段”,这意味着具体用法可能还会微调,但大方向已定。

我们终于要和 Date 的噩梦说再见了。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs) ,每天分享前端知识、AI 干货。

前端向架构突围系列 - 框架设计(三):用开闭原则拯救你的组件库

写在前面

兄弟们,回想一下,你有没有接过这种需求:

产品经理跑来说:“咱们那个通用的表格组件,现在需要在某一列加个自定义的渲染逻辑,以前是纯文本,现在要变成个带图标的按钮,还能点击弹窗。”

你心想:“这还不简单?”

于是你打开了那个祖传的 CommonTable.vueTable.tsx,找到了渲染单元格的地方,熟练地写下了一个 if-else

过了两天,产品又来了:“那啥,另一列也要改,这次要加个进度条。”

你又熟练地加了一个 else-if

几个月后,这个组件的源码已经突破了 2000 行,光那个 if-else 的判断逻辑就占了半屏。后来的同事接手时,看着这坨代码,只想把你拉黑。

这种“改哪哪疼,牵一发而动全身”的代码,就是典型的违反了开闭原则 (Open/Closed Principle, OCP) 。今天咱们就来聊聊,怎么用 OCP 把这坨代码重构成“人话”。


39072abf-1240-4203-a664-62f3074c67cd.png

什么是开闭原则 (OCP)?

开闭原则,听起来很高大上,其实说人话就是八个字:

对扩展开放,对修改关闭。

  • 对扩展开放 (Open for extension) :当有新需求来了,你应该能通过“增加新代码”的方式来满足,而不是去改旧代码。
  • 对修改关闭 (Closed for modification) :那个已经写好、测试过、稳定运行的核心代码,你尽量别去动它。

想象一下你的电脑主机。你想加个显卡,是直接把主板焊开接线(修改),还是找个 PCI-E 插槽插上去(扩展)?显然后者更靠谱。

在前端领域,OCP 最典型的应用场景就是组件设计插件系统


案例分析:一个“违反 OCP”的糟糕组件

咱们就拿最常见的通用列表项组件来举例。假设我们有一个 ListItem 组件,用来展示用户信息。

原始需求

需求很简单:展示用户的头像和名字。

// ListItem.tsx (V1)
interface User {
  id: string;
  name: string;
  avatar: string;
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
    </div>
  );
};

这代码看起来没毛病,清爽、简单。

需求变更 1:加个 VIP 标志

产品说:“有些用户是 VIP,名字后面得加个金灿灿的皇冠图标。”

你心想,小case,一把梭:

// ListItem.tsx (V2 - 开始变味了)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean; // 新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <img src={user.avatar} alt={user.name} />
      <span>{user.name}</span>
      {/* 修改点:硬编码逻辑 */}
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

你为了这个新需求,修改ListItem 组件的内部实现。虽然只加了一行,但坏头已经开了。

需求变更 2:再加个在线状态

产品又来了:“得显示用户在不在线,在线的头像旁边亮个绿灯。”

你叹了口气,继续梭:

// ListItem.tsx (V3 - 味道越来越冲)
interface User {
  id: string;
  name: string;
  avatar: string;
  isVip?: boolean;
  isOnline?: boolean; // 又新增字段
}

const ListItem = ({ user }: { user: User }) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        <img src={user.avatar} alt={user.name} />
        {/* 修改点:又硬编码逻辑 */}
        {user.isOnline && <span className="online-dot"></span>}
      </div>
      <span>{user.name}</span>
      {user.isVip && <span className="vip-icon"></span>}
    </div>
  );
};

问题来了:

  1. 组件越来越臃肿:每次新需求都要改这个文件,代码量蹭蹭涨。
  2. 耦合度极高ListItem 竟然要知道什么是 VIP,什么是在线状态。如果明天要加个“等级勋章”、“活动挂件”呢?
  3. 测试困难:每次改动都得把以前的 VIP、在线状态全测一遍,生怕改坏了。

这就是典型的违反了对修改关闭。核心组件被迫了解太多它不该知道的业务逻辑。


重构:用 OCP 把“屎山”铲平

怎么让 ListItem 既能支持各种花里胡哨的展示,又不用每次都改它呢?

答案就是:把变化的部分抽离出去,留下不变的骨架。

  • 不变的部分:列表项的基本结构(左边是图,右边是文字)。
  • 变化的部分:头像旁边要加什么装饰?文字后面要挂什么配件?

我们可以利用 React 的 组合 (Composition) 特性,比如 children 或者 Render Props(插槽槽位)。

重构 V1:使用插槽 (Slots / Render Props)

我们改造一下 ListItem,让它别管那么多闲事,只负责提供“坑位”。

// ListItem.tsx (OCP版本)
interface ListItemProps {
  avatar: React.ReactNode; // 不再只传字符串,直接传节点
  title: React.ReactNode;  // 同上
  // 预留两个扩展槽位
  avatarAddon?: React.ReactNode;
  titleAddon?: React.ReactNode;
}

// 这个组件现在稳定得一批,几乎不需要再修改了
const ListItem = ({ avatar, title, avatarAddon, titleAddon }: ListItemProps) => {
  return (
    <div className="list-item">
      <div className="avatar-wrapper">
        {avatar}
        {/* 扩展点:头像装饰 */}
        {avatarAddon}
      </div>
      <div className="title-wrapper">
        {title}
        {/* 扩展点:标题装饰 */}
        {titleAddon}
      </div>
    </div>
  );
};

现在,核心组件 ListItem 对修改是关闭的。那怎么扩展新需求呢?

在使用它的地方进行扩展(对扩展开放):

// UserList.tsx (业务层)
import ListItem from './ListItem';

const UserList = ({ users }) => {
  return (
    <div>
      {users.map(user => (
        <ListItem
          key={user.id}
          // 基础信息
          avatar={<img src={user.avatar} />}
          title={<span>{user.name}</span>}
          // 扩展需求1:在线状态
          avatarAddon={user.isOnline ? <OnlineDot /> : null}
          // 扩展需求2:VIP标识
          titleAddon={user.isVip ? <VipCrown /> : null}
        />
      ))}
    </div>
  );
};

看!世界清静了。

  • ListItem 组件不知道也不关心什么是 VIP。它只知道:“如果有人给了我 titleAddon,那我就把它渲染在标题后面。”
  • 如果明天产品要加个“等级勋章”,你只需要写个 <LevelBadge /> 组件,然后传给 titleAddon 即可。ListItem.tsx 文件一个字都不用改。

这就是 OCP 的魅力。


进阶:策略模式与配置化

在更复杂的场景下,比如我们开头提到的通用表格组件,每一列的渲染逻辑可能千奇百怪。这时候光用插槽可能还不够灵活。

我们可以借鉴策略模式的思想,结合配置化来实现 OCP。

假设我们有一个复杂的后台管理表格。

糟糕的设计 (违反 OCP)

// BadTableColumn.tsx
const renderCell = (value, columnType) => {
  // 地狱 if-else 
  if (columnType === 'text') {
    return <span>{value}</span>;
  } else if (columnType === 'image') {
    return <img src={value} />;
  } else if (columnType === 'link') {
    // ...要加新类型就得改这里
  } else if (columnType === 'status') {
     // ...越来越长
  }
  // ...
};

符合 OCP 的设计

我们定义一个策略注册表,把每种类型的渲染逻辑注册进去。

// renderStrategies.tsx (策略定义)
const strategies = {
  text: (value) => <span>{value}</span>,
  image: (value) => <img src={value} className="table-img" />,
  // 新需求:状态标签
  status: (value) => <Tag color={value === 'active' ? 'green' : 'red'}>{value}</Tag>,
};

// 提供注册入口(对扩展开放)
export const registerStrategy = (type, renderer) => {
  strategies[type] = renderer;
};

// 提供获取入口
export const getStrategy = (type) => {
  return strategies[type] || strategies['text'];
};

然后,表格组件只负责调用策略:

// GoodTableColumn.tsx
import { getStrategy } from './renderStrategies';

const TableCell = ({ value, columnType }) => {
  // 核心组件对修改关闭:它不需要知道具体怎么渲染
  const renderer = getStrategy(columnType);
  return <td>{renderer(value)}</td>;
};

当你要新增一种“进度条”类型的列时,你根本不需要碰 TableCell 组件,只需要在项目的入口文件里注册一个新的策略:

// main.js (应用入口)
import { registerStrategy } from './renderStrategies';
import ProgressBar from './components/ProgressBar';

// 扩展新能力
registerStrategy('progress', (value) => <ProgressBar percent={value} />);

这就实现了一个简易的插件化系统。核心库稳定不变,业务方通过注册机制无限扩展能力。


总结:别让自己成为“改Bug机器”

开闭原则不是什么高深的理论,它就是为了让你少加班、少背锅而生的。

记住这几个实战要点:

  1. 识别变化点:做组件之前先想想,哪些是铁打不动的骨架,哪些是流水易变的皮肉。
  2. 多用组合/插槽:React 的 children 和 Render Props,Vue 的 slot,都是实现 OCP 的利器。把决定权交给使用者,而不是自己大包大揽。
  3. 善用策略/配置:遇到复杂的 if-else 逻辑判断渲染类型时,考虑用映射表(Map 对象)代替硬编码,把逻辑抽离出去。

下次再遇到产品经理不断提新需求,希望你能自信地打开代码,优雅地新增一个文件,而不是痛苦地在那坨几千行的祖传代码里加 if-else

Keep coding, keep open!


互动话题

你的项目里有没有那种因为违反 OCP 而变得维护困难的“超级组件”?你又是怎么重构它的?欢迎在评论区吐槽交流!

🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)

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

rrweb 的核心魅力在于 “用极小的数据量复现完整的页面操作”,这背后是 DOM 快照、增量更新、事件序列化等技术的精密协作。本文将从原理本质出发,用通俗类比 + 源码解析的方式,拆解每个技术模块的实现逻辑,同时揭示 rrweb 在兼容性、性能优化上的关键设计。

一、 DOM 快照生成原理(snapshot)

通俗类比:DOM 快照就像给页面拍 “全景工程图”—— 不是简单记录像素(如截图),而是把页面的 “骨架”(DOM 层级结构)、“皮肤”(CSS 样式)、“零件参数”(元素属性、文本内容)都转化为结构化数据,后续能根据这张 “工程图” 1:1 还原出与原页面一致的初始状态。区别于普通照片,这张 “工程图” 包含所有可编辑的 “零件信息”,而非固定的视觉图像。

1.1 核心目标与技术路径

DOM 快照的核心目标是生成 “可序列化、可重建、体积小” 的 DOM 数据,技术路径分为三步:DOM 遍历与节点过滤节点属性与样式序列化资源引用处理,最终输出 JSON 格式的FullSnapshot事件(rrweb 事件类型标识为 2)。

1.2 关键实现逻辑(参考 rrweb-snapshot 源码)

rrweb-snapshot 库的 takeSnapshot 函数是快照生成的核心,关键步骤如下:

1.2.1 根节点遍历与过滤

document.documentElement(HTML 根节点)开始深度优先遍历,跳过无需录制的节点(如script、style标签,或带屏蔽类名的元素),避免无效数据占用空间:

// 伪代码:DOM节点遍历逻辑
function traverseNode(node, config) {
  // 过滤规则:跳过脚本、样式、屏蔽元素、不可见元素
  const isIgnored = 
    node.tagName === 'SCRIPT' || 
    node.tagName === 'STYLE' || 
    node.classList.contains(config.blockClass) || 
    window.getComputedStyle(node).display === 'none';
  if (isIgnored) return null;
  // 序列化当前节点
  const nodeSnapshot = serializeNode(node);
  // 递归处理子节点(构建DOM树结构)
  const children = [];
  for (const child of node.childNodes) {
    const childSnapshot = traverseNode(child, config);
    if (childSnapshot) children.push(childSnapshot);
  }
  nodeSnapshot.children = children;
  // 内联计算样式(确保回放样式一致)
  inlineComputedStyle(node, nodeSnapshot);
  return nodeSnapshot;
}

1.2.2 节点序列化(serializeNode)

提取节点关键属性,转化为 JSON 结构,重点处理元素节点与文本节点:

// 伪代码:节点序列化
function serializeNode(node) {
  const snapshot = {
    type: node.nodeType, // 1=元素节点,3=文本节点,8=注释节点(跳过)
  };
  if (node.nodeType === 1) { // 元素节点
    snapshot.tagName = node.tagName.toLowerCase(); // 统一小写(如DIV→div)
    snapshot.attributes = {};
    // 收集核心属性(id、class、src、href等,跳过自定义无关属性)
    const coreAttrs = ['id', 'class', 'src', 'href', 'alt', 'title', 'value', 'checked'];
    for (const attr of node.attributes) {
      if (coreAttrs.includes(attr.name) || attr.name.startsWith('data-')) {
        snapshot.attributes[attr.name] = attr.value;
      }
    }
    // 特殊处理表单元素(记录当前值,而非初始值)
    if (['INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName)) {
      snapshot.value = node.value;
      snapshot.checked = node.checked || false;
      snapshot.selectedIndex = node.tagName === 'SELECT' ? node.selectedIndex : -1;
    }
  } else if (node.nodeType === 3) { // 文本节点
    snapshot.text = node.textContent.trim() || ''; // 过滤空文本,减少体积
  }
  return snapshot;
}

1.2.3 样式收集与内联

页面样式可能来自link、style或内联style,为避免回放时样式丢失,rrweb 会将计算样式(computedStyle) 内联到节点快照中,同时过滤默认样式减少数据量:

// 伪代码:样式内联处理
function inlineComputedStyle(node, snapshot) {
  const computedStyle = window.getComputedStyle(node);
  const styles = {};
  // 仅收集影响视觉的关键样式属性(排除默认值)
  const criticalStyles = [
    'display', 'position', 'top', 'left', 'width', 'height', 
    'color', 'background', 'font-size', 'border', 'padding', 'margin'
  ];
  for (const prop of criticalStyles) {
    const value = computedStyle.getPropertyValue(prop);
    // 过滤默认样式(如div的display:block、body的margin:8px)
    if (!isDefaultStyle(snapshot.tagName, prop, value)) {
      styles[prop] = value;
    }
  }
  if (Object.keys(styles).length > 0) {
    snapshot.styles = styles; // 仅当有非默认样式时才添加,减少体积
  }
}
// 辅助函数:判断是否为标签默认样式(基于rrweb内置的默认样式表)
function isDefaultStyle(tagName, prop, value) {
  const defaultStyles = {
    div: { display: 'block', margin: '0' },
    body: { margin: '8px', color: 'rgb(0, 0, 0)' },
    input: { border: '1px solid rgb(169, 169, 169)' }
    // 其他标签默认样式...
  };
  return defaultStyles[tagName]?.[prop] === value;
}

1.3 技术难点与解决方案

  • 难点 1:样式体积过大与浏览器兼容性

    直接收集所有计算样式会导致数据量激增(单个元素可能有上百个样式属性),且不同浏览器默认样式存在差异(如 Chrome 与 Safari 的body默认margin不同)。

    解决方案: ① 仅收集 “影响视觉的关键样式”(如display、position),过滤无关样式(如webkit-font-smoothing);

    ② 维护 “标签默认样式表”,仅记录与默认值不同的样式,数据量可减少 60% 以上;

    ③ 对浏览器私有前缀样式(如-webkit-border-radius)进行兼容处理,统一转化为标准样式。

  • 难点 2:跨域资源无法加载

    页面中的跨域图片(如 CDN 图片)、字体等资源,回放时可能因 CORS 限制无法加载,导致样式错乱。

    解决方案

  • ① 配置inlineImages: true时,将图片转化为 Base64 编码内联到快照中(适合小图片);

  • ② 对大图片,回放时通过 “同源代理服务” 转发请求(如后端部署代理接口,将跨域图片 URL 转为同源 URL);

  • ③ 记录资源加载失败时的降级样式(如图片占位符),确保回放体验一致。

1.4 DOM 快照生成流程图

exported_image.png

二、增量更新机制(MutationObserver)

通俗类比:如果说 DOM 快照是 “初始工程图”,增量更新就是 “工程变更记录”—— 就像建筑施工时,不需要每次都重新绘制完整图纸,只需记录 “在 3 层增加 1 个窗户” “修改 2 层墙体颜色” 这类变化。

rrweb 通过监听 DOM 变化,只记录快照后的增量修改,大幅减少录制数据量。

2.1 核心技术:MutationObserver API

rrweb 的增量更新完全依赖浏览器原生的MutationObserver API,该 API 可监听 DOM 树的 “节点变化、属性变化、文本变化” ,并异步批量触发回调(避免阻塞主线程)。

其核心优势:

  • ① 精准监听(可指定监听类型);
  • ② 批量处理(短时间内多次变化合并为一次回调);
  • ③ 性能友好(异步执行,不阻塞用户交互)。

2.2 监听配置与事件转化

rrweb 初始化时创建 MutationObserver实例,将原生变化事件转化为自定义的Mutation事件(rrweb 事件类型标识为 3),关键逻辑如下:

2.2.1 MutationObserver 初始化

// 伪代码:rrweb中MutationObserver配置
function initMutationObserver(emit, config) {
  // 回调函数:批量处理DOM变化
  const observerCallback = (mutations) => {
    // 过滤无需记录的微小变化(如文本节点空字符修改)
    const validMutations = mutations.filter(m => isMutationValid(m, config));
    if (validMutations.length === 0) return;
    // 批量转化为rrweb增量事件并发送
    validMutations.forEach(mutation => {
      const incrementalEvent = transformMutation(mutation);
      emit(incrementalEvent); // 发送到事件队列,后续存储/上传
    });
  };
  // 监听配置:覆盖所有关键变化类型
  const observerConfig = {
    childList: true,          // 监听子节点新增/删除
    attributes: true,         // 监听元素属性变化
    characterData: true,      // 监听文本节点内容变化
    subtree: true,            // 深度监听(子树所有节点,不仅是直接子节点)
    attributeOldValue: true,  // 记录属性变化前的旧值(便于回放时还原)
    characterDataOldValue: true // 记录文本变化前的旧值
  };
  // 开始监听根节点
  const observer = new MutationObserver(observerCallback);
  observer.observe(document.documentElement, observerConfig);
  return observer; // 返回实例,便于后续停止监听
}
// 辅助函数:过滤无效变化(如屏蔽元素内的变化、空文本修改)
function isMutationValid(mutation, config) {
  // 屏蔽元素内的变化不记录
  if (mutation.target.closest(`.${config.blockClass}`)) return false;
  // 文本节点空字符修改不记录(如用户输入后删除为空)
  if (mutation.type === 'characterData') {
    return mutation.oldValue.trim() !== '' || mutation.target.textContent.trim() !== '';
  }
  return true;
}

2.2.2 原生变化事件转化(transformMutation)

将浏览器原生的MutationRecord转化为 “可回放的结构化数据”,核心是明确 “变化目标、变化类型、变化内容”:

// 伪代码:转化原生MutationRecord
function transformMutation(mutation) {
  const event = {
    type: 3,                  // Mutation事件类型标识
    timestamp: Date.now(),    // 事件发生时间戳(用于回放排序)
    data: {}
  };
  // 1. 子节点变化(新增/删除节点)
  if (mutation.type === 'childList') {
    event.data.type = 'childList';
    // 记录父节点路径(便于回放时定位目标父节点)
    event.data.parentPath = getNodeUniquePath(mutation.target);
    // 序列化新增/删除的节点(仅核心属性,减少体积)
    event.data.addedNodes = mutation.addedNodes.map(n => serializeNode(n)).filter(Boolean);
    event.data.removedNodes = mutation.removedNodes.map(n => serializeNode(n)).filter(Boolean);
    // 记录插入位置参考节点(确保回放时插入顺序正确)
    event.data.nextSiblingId = mutation.nextSibling 
      ? getNodeUniqueId(mutation.nextSibling) 
      : null;
  }
  // 2. 属性变化(如class、src修改)
  else if (mutation.type === 'attributes') {
    event.data.type = 'attributes';
    event.data.targetPath = getNodeUniquePath(mutation.target);
    event.data.attributeName = mutation.attributeName;
    event.data.oldValue = mutation.oldValue;
    event.data.newValue = mutation.target.getAttribute(mutation.attributeName);
  }
  // 3. 文本变化(如span内文本修改)
  else if (mutation.type === 'characterData') {
    event.data.type = 'characterData';
    event.data.targetPath = getNodeUniquePath(mutation.target);
    event.data.oldValue = mutation.oldValue;
    event.data.newValue = mutation.target.textContent;
  }
  return event;
}
// 辅助函数:生成节点唯一路径(如"body>div.container>ul>li:nth-child(2)")
function getNodeUniquePath(node) {
  if (node === document.documentElement) return 'html';
  if (node === document.body) return 'body';
  const parentPath = getNodeUniquePath(node.parentElement);
  const siblings = Array.from(node.parentElement.children);
  // 用"标签名+索引"确保唯一性(如li:nth-child(2))
  const index = siblings.indexOf(node) + 1;
  const nodeName = node.tagName.toLowerCase();
  const classAttr = node.classList.length > 0 
    ? `.${Array.from(node.classList).join('.')}` 
    : '';
  const idAttr = node.id ? `#${node.id}` : '';
  return `${parentPath}>${nodeName}${idAttr}${classAttr}:nth-child(${index})`;
}

2.3 技术难点与解决方案

  • 难点 1:节点路径定位不准确

    增量变化需要明确 “哪个节点发生了变化”,但 DOM 节点没有天生的唯一标识,动态生成的节点(如 Vue 列表渲染的li)在刷新后路径可能变化,导致回放时无法定位目标节点。

    解决方案

    • ① 生成 “唯一路径”(结合标签名、ID、类名、兄弟节点索引),如body>div.container>ul>li:nth-child(2),确保即使节点动态更新,仍能通过路径找到;

    • ② 维护 “节点 ID 映射表”,录制时为每个节点分配临时 ID(如node.__rrwebId),回放时通过 ID 快速定位,路径作为降级方案(应对 ID 丢失场景)。

  • 难点 2:高频变化导致性能卡顿

    页面中的高频 DOM 变化(如倒计时、动画、滚动加载列表)会触发MutationObserver频繁回调(如每秒几十次),导致前端主线程阻塞,影响用户交互体验。

    解决方案

    • ① 结合 “采样率控制”,对高频变化事件(如文本倒计时)进行节流处理(如 100ms 内仅记录 1 次变化);
    • ② 批量合并短时间内的同类变化(如 100ms 内的多次文本修改合并为 1 次);
    • ③ 过滤 “无意义变化”(如元素scrollTop的微小波动、空文本修改),减少事件数量。

2.4 增量更新流程示意图

exported_image.png

三、事件捕获和序列化

通俗类比:如果说 DOM 快照是 “初始场景”,增量更新是 “场景变化”,那么事件捕获就是 “用户动作剧本”—— 就像电影拍摄时,不仅要搭建场景,还要记录演员的 “肢体动作”“台词”“表情”。

rrweb 需要捕获用户的点击、输入、滚动等交互动作,将其转化为结构化的 “剧本数据”,确保回放时能精准还原用户操作轨迹。

3.1 核心事件类型与捕获策略

rrweb 主要捕获 6 类高频用户交互事件,覆盖 90% 以上的前端操作场景,采用 “全局事件委托 + 精准过滤” 的捕获策略,避免给每个节点绑定事件导致内存泄漏:

事件类型 核心用途 关键数据字段 rrweb 事件类型标识
鼠标点击(click) 记录按钮、链接等点击操作 点击坐标(x/y)、目标节点路径 4
键盘输入(keydown) 记录文本输入、功能键操作 按键码(keyCode)、输入内容、目标节点 5
鼠标移动(mousemove) 记录鼠标位置变化 鼠标坐标(x/y)、时间戳 6
滚动(scroll) 记录页面 / 元素滚动位置 滚动目标路径、scrollTop/scrollLeft 7
窗口 resize 记录窗口尺寸变化 窗口宽(width)、高(height) 8
表单提交(submit) 记录表单提交操作 表单节点路径、提交时间戳 9

3.2 关键实现逻辑(全局事件委托)

通过在document上绑定事件监听器,利用事件冒泡机制捕获所有子节点的交互,核心代码如下:

// 伪代码:rrweb事件捕获核心逻辑
function initEventCapture(emit, config) {
  // 需捕获的事件类型与对应的处理函数
  const eventHandlers = {
    click: handleClick,
    keydown: handleKeydown,
    mousemove: handleMousemove,
    scroll: handleScroll,
    resize: handleResize,
    submit: handleSubmit
  };
  // 绑定全局事件委托
  Object.entries(eventHandlers).forEach(([type, handler]) => {
    document.addEventListener(type, (e) => {
      // 过滤规则:1. 屏蔽元素内的事件 2. 非用户触发的事件(如脚本触发的click)
      if (isIgnoredEvent(e, config)) return;
      // 处理事件并序列化为结构化数据
      const eventData = handler(e, config);
      // 发送事件(携带时间戳,确保回放顺序)
      emit({
        type: getRRwebEventType(type), // 转化为rrweb事件标识
        timestamp: Date.now(),
        data: eventData
      });
    }, {
      passive: type === 'scroll' || type === 'resize', // passive优化:避免滚动阻塞
      capture: false // 冒泡阶段捕获,确保能获取最终目标节点
    });
  });
}
// 辅助函数:过滤无效事件
function isIgnoredEvent(e, config) {
  // 1. 屏蔽元素内的事件(如带.rr-block类名的元素)
  if (e.target.closest(`.${config.blockClass}`)) return true;
  // 2. 排除脚本触发的事件(仅保留用户手动触发)
  if (e.isTrusted === false) return true;
  // 3. 排除右键点击(contextmenu)和滚轮事件(默认不捕获)
  if (e.type === 'click' && e.button === 2) return true;
  return false;
}

3.3 典型事件序列化实现(以键盘输入和滚动为例)

不同事件的序列化重点不同,需针对性处理敏感数据(如密码)和冗余信息:

3.3.1 键盘输入事件(keydown)序列化

需区分 “普通字符输入” 和 “功能键”,同时对密码等敏感输入进行掩码处理:

// 伪代码:键盘输入事件处理
function handleKeydown(e, config) {
  const target = e.target;
  // 隐私处理:密码输入框且开启掩码,不记录真实内容
  const isPasswordInput = target.tagName === 'INPUT' && target.type === 'password';
  const shouldMask = isPasswordInput && config.maskInputPassword;
  return {
    targetPath: getNodeUniquePath(target), // 目标输入框路径
    key: shouldMask ? '*' : e.key, // 敏感输入替换为*
    keyCode: e.keyCode, // 按键码(回放时模拟输入需用到)
    value: shouldMask ? '*' : target.value, // 输入框当前值(非敏感场景)
    isFunctionalKey: ['Enter', 'Backspace', 'Tab'].includes(e.key) // 是否为功能键
  };
}

3.3.2 滚动事件(scroll)序列化

需区分 “页面滚动” 和 “元素滚动”,避免重复记录高频滚动事件:

// 伪代码:滚动事件处理(含节流优化)
let lastScrollTime = 0;
function handleScroll(e, config) {
  const now = Date.now();
  // 节流优化:50ms内仅记录1次,减少高频滚动导致的数据量
  if (now - lastScrollTime < 50) return null;
  lastScrollTime = now;
  // 区分页面滚动和元素滚动
  const target = e.target === document ? document.documentElement : e.target;
  return {
    targetPath: getNodeUniquePath(target),
    scrollTop: target.scrollTop,
    scrollLeft: target.scrollLeft,
    isPageScroll: e.target === document // 是否为页面滚动
  };
}

3.4 技术难点与解决方案

  • 难点 1:事件顺序错乱

    不同事件的触发存在时间差(如 “点击按钮→输入文本→提交表单”),若录制时事件顺序错误,回放会出现逻辑混乱(如未输入就提交)。

    解决方案

    • ① 所有事件携带精确时间戳(Date.now()),回放时按时间戳升序执行;

    • ② 对存在依赖关系的事件(如 “click” 后触发的 “keydown”),记录事件间的关联 ID(如parentEventId),确保回放时顺序一致。

3.5 事件捕获流程示意图

image.png

四、回放还原技术

通俗类比:回放就像 “按剧本复现舞台剧”——DOM 快照是 “初始舞台布置”,增量事件是 “舞台道具变化指令”,用户交互事件是 “演员动作指令”,rrweb-player 则是 “导演”,按时间顺序执行所有指令,最终还原完整场景。

4.1 回放核心流程

回放过程分为 “初始化准备” “事件调度” “状态同步” 三步,核心是 “按时间戳排序事件” 与 “精准执行指令”:

4.1.1 初始化准备(加载快照)

回放开始时,先基于FullSnapshot事件重建初始 DOM,再初始化节点路径映射表(便于后续定位目标节点):

// 伪代码:回放初始化
class RRwebPlayer {
  constructor(options) {
    this.events = options.events.sort((a, b) => a.timestamp - b.timestamp); // 按时间戳排序
    this.container = options.target; // 回放容器
    this.nodeMap = new Map(); // 节点ID→DOM节点映射表
    this.initSnapshot(); // 加载初始快照
  }
  // 基于FullSnapshot重建初始DOM
  initSnapshot() {
    // 找到首屏快照事件
    const fullSnapshot = this.events.find(e => e.type === 2);
    if (!fullSnapshot) throw new Error('缺少首屏快照,无法回放');
    
    // 清空容器,重建DOM
    this.container.innerHTML = '';
    const rootNode = this.rebuildNode(fullSnapshot.data.node);
    this.container.appendChild(rootNode);
    
    // 初始化节点映射表(记录每个节点的唯一ID)
    this.buildNodeMap(rootNode);
  }
  // 从快照节点重建真实DOM
  rebuildNode(snapshotNode) {
    let node;
    if (snapshotNode.type === 1) { // 元素节点
      node = document.createElement(snapshotNode.tagName);
      // 还原属性(id、class、src等)
      Object.entries(snapshotNode.attributes || {}).forEach(([key, value]) => {
        node.setAttribute(key, value);
      });
      // 还原样式(内联快照中的非默认样式)
      if (snapshotNode.styles) {
        Object.entries(snapshotNode.styles).forEach(([key, value]) => {
          node.style[key] = value;
        });
      }
      // 还原表单状态(value、checked)
      if (['INPUT', 'TEXTAREA', 'SELECT'].includes(snapshotNode.tagName.toUpperCase())) {
        node.value = snapshotNode.value || '';
        if (snapshotNode.checked !== undefined) node.checked = snapshotNode.checked;
      }
    } else if (snapshotNode.type === 3) { // 文本节点
      node = document.createTextNode(snapshotNode.text || '');
    }
    // 递归重建子节点
    if (snapshotNode.children && snapshotNode.children.length > 0) {
      snapshotNode.children.forEach(childSnapshot => {
        const childNode = this.rebuildNode(childSnapshot);
        if (childNode) node.appendChild(childNode);
      });
    }
    return node;
  }
}

4.1.2 事件调度(按时间顺序执行)

采用 “定时器 + 事件队列” 模式,模拟真实时间流逝,按事件 timestamp 差值执行指令,支持倍速播放(如 1x、2x、4x):

// 伪代码:事件调度逻辑
class RRwebPlayer {
  // ... 初始化逻辑 ...
  // 开始回放
  play(speed = 1) {
    this.isPlaying = true;
    this.playSpeed = speed;
    this.currentEventIndex = 0; // 当前执行到的事件索引
    this.startTime = Date.now(); // 回放开始时间
    this.firstEventTime = this.events[0].timestamp; // 第一个事件的时间戳
    
    // 启动调度器
    this.scheduler = setInterval(() => this.executeEvents(), 16); // 约60fps,流畅度优先
  }
  // 执行当前时间点应触发的事件
  executeEvents() {
    if (!this.isPlaying) return;
    const currentPlayTime = this.firstEventTime + (Date.now() - this.startTime) * this.playSpeed;
    
    // 执行所有timestamp <= 当前播放时间的事件
    while (this.currentEventIndex < this.events.length) {
      const event = this.events[this.currentEventIndex];
      if (event.timestamp > currentPlayTime) break;
      
      // 根据事件类型执行对应操作
      this.executeEvent(event);
      this.currentEventIndex++;
    }
    // 回放结束,清除定时器
    if (this.currentEventIndex >= this.events.length) {
      this.pause();
      this.onFinish?.();
    }
  }
  // 执行单个事件
  executeEvent(event) {
    switch (event.type) {
      case 3: // Mutation事件(增量更新)
        this.executeMutation(event.data);
        break;
      case 4: // click事件
        this.executeClick(event.data);
        break;
      case 5: // 键盘事件
        this.executeKeyEvent(event.data);
        break;
      // 其他事件类型执行逻辑...
    }
  }
}

4.1.3 增量事件执行(以 Mutation 为例)

根据增量事件类型,执行 “节点新增 / 删除”“属性修改”“文本更新” 等操作:

// 伪代码:执行Mutation增量事件
class RRwebPlayer {
  // ... 其他方法 ...
  executeMutation(mutationData) {
    // 定位目标节点(通过路径或节点ID)
    const targetNode = this.getTargetNode(mutationData.targetPath || mutationData.parentPath);
    if (!targetNode) return;
    switch (mutationData.type) {
      case 'childList': // 子节点变化
        // 新增节点
        mutationData.addedNodes.forEach(childSnapshot => {
          const childNode = this.rebuildNode(childSnapshot);
          if (mutationData.nextSiblingId) {
            // 插入到参考节点之前
            const nextSibling = this.nodeMap.get(mutationData.nextSiblingId);
            targetNode.insertBefore(childNode, nextSibling);
          } else {
            // 插入到末尾
            targetNode.appendChild(childNode);
          }
          // 更新节点映射表
          this.buildNodeMap(childNode);
        });
        // 删除节点
        mutationData.removedNodes.forEach(childSnapshot => {
          const childNode = this.getTargetNodeBySnapshot(childSnapshot);
          if (childNode && childNode.parentElement === targetNode) {
            targetNode.removeChild(childNode);
            this.nodeMap.delete(childNode.__rrwebId); // 从映射表移除
          }
        });
        break;
      case 'attributes': // 属性修改
        targetNode.setAttribute(mutationData.attributeName, mutationData.newValue);
        break;
      case 'characterData': // 文本修改
        targetNode.textContent = mutationData.newValue;
        break;
    }
  }
}

4.2 技术难点与解决方案

  • 难点 1:事件执行顺序偏差

录制时事件按真实时间戳存储,但回放时若定时器精度不足(如 setInterval 存在延迟),可能导致 “先执行点击、后执行 DOM 更新” 的顺序错误,引发场景混乱。

解决方案: ① 事件队列按 timestamp 严格排序,执行时通过 “当前播放时间 = 首事件时间 +(当前时间 - 回放开始时间)× 倍速” 精准计算应执行的事件;

② 对依赖 DOM 状态的事件(如 click),增加 “DOM 就绪检查”,确保增量更新执行完成后再触发交互事件。

  • 难点 2:回放时节点定位失败

若录制时 DOM 结构动态变化(如 Vue 列表重新渲染),回放时可能出现 “路径找不到节点” 的问题。

解决方案

  • ① 采用 “节点 ID 优先、路径降级” 的定位策略,录制时为每个节点分配\_\_rrwebId,回放时优先通过 ID 定位;
  • ② 路径定位时支持 “模糊匹配”(如忽略动态索引,通过类名 + 标签名组合定位);
  • ③ 定位失败时触发 “降级处理”(如跳过该事件,避免整个回放崩溃)。
❌