阅读视图
【React 19 尝鲜】第一篇:use和useActionState
JS判断空值只知道“||”?不如来试试这个操作符
前端面试必问 asyncawait 到底要不要加 trycatch 90% 人踩坑 求职加分技巧揭秘
打造 AI 驱动的 Git 提交规范助手:基于 React + Express + Ollama+langchain 的全栈实践
第11章 LangChain
LangChain 是一个用于开发基于大语言模型(LLM)应用程序的开源框架,它通过提供模块化的抽象组件和链式调用工具,将 LLM 与外部数据源(如文档、数据库)和计算工具(如搜索引擎、代码解释器)智能连接,从而构建出具备记忆、推理和行动能力的增强型 AI 应用,典型场景包括智能问答、内容生成和智能体(Agent)系统。
LangChain官方文档:docs.langchain.com/。网址的组成逻辑和Ne…
LangChain支持Python和TypeScript两种编程语言,如图11-1所示。
图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选项。官方文档有对应的使用说明。
图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值,就看你心情了。
图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。只要大概知道很便宜就足够了。
图11-4 DeepSeek模型选择
temperature字段是温度的含义,在DeepSeek官方文档中有直接给出对应的建议,我们的示例是打算用于对话,因此设置1.3就足够了,Temperature参数设置如图11-5所示。
从应用场景,我们可以理解为temperature字段大概是理性与感性的权衡度,逻辑性越强的场景,温度越低;越感性的场景温度越高。所有AI大模型都是类似的,从他们对应的官方文档去获取对应信息就可以了。
图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所示。
图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请求而出现问题。
图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对话和基础提示词的案例。
启动 Taro 4 项目报错:Error: The specified module could not be found.
docker+nginx部署
开发一个美观的 VitePress 图片预览插件
webpack/vite配置
小程序上线半年我赚了多少钱?
一篇文章带你搞懂原型和原型链
推荐几个国外比较流行的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>
效果
2、Bootstrap
虽然 Bootstrap 已经很多年了,但说实话,在一些需求明确、节奏比较快的项目里,它依然很好用。栅格、常见组件基本都有,直接拼就能出页面,几乎不用想太多。
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>
效果
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>
效果
react使用Ant Design
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.vue或Table.tsx,找到了渲染单元格的地方,熟练地写下了一个if-else。过了两天,产品又来了:“那啥,另一列也要改,这次要加个进度条。”
你又熟练地加了一个
else-if。几个月后,这个组件的源码已经突破了 2000 行,光那个
if-else的判断逻辑就占了半屏。后来的同事接手时,看着这坨代码,只想把你拉黑。这种“改哪哪疼,牵一发而动全身”的代码,就是典型的违反了开闭原则 (Open/Closed Principle, OCP) 。今天咱们就来聊聊,怎么用 OCP 把这坨代码重构成“人话”。
什么是开闭原则 (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>
);
};
问题来了:
- 组件越来越臃肿:每次新需求都要改这个文件,代码量蹭蹭涨。
-
耦合度极高:
ListItem竟然要知道什么是 VIP,什么是在线状态。如果明天要加个“等级勋章”、“活动挂件”呢? - 测试困难:每次改动都得把以前的 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机器”
开闭原则不是什么高深的理论,它就是为了让你少加班、少背锅而生的。
记住这几个实战要点:
- 识别变化点:做组件之前先想想,哪些是铁打不动的骨架,哪些是流水易变的皮肉。
-
多用组合/插槽:React 的
children和 Render Props,Vue 的slot,都是实现 OCP 的利器。把决定权交给使用者,而不是自己大包大揽。 -
善用策略/配置:遇到复杂的
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 快照生成流程图
二、增量更新机制(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 增量更新流程示意图
三、事件捕获和序列化
通俗类比:如果说 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 事件捕获流程示意图
四、回放还原技术
通俗类比:回放就像 “按剧本复现舞台剧”——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 定位; - ② 路径定位时支持 “模糊匹配”(如忽略动态索引,通过类名 + 标签名组合定位);
- ③ 定位失败时触发 “降级处理”(如跳过该事件,避免整个回放崩溃)。