阅读视图

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

第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对话和基础提示词的案例。

启动 Taro 4 项目报错:Error: The specified module could not be found.

最近在 Windows 上 clone 了一个之前就写好的 Taro 4.x 小程序项目,本地用 VS Code 启动的时候直接报错:

Error: The specified module could not be found.
…/node_modules/@tarojs/plugin-doctor-win32-x64-msvc/
taro-doctor.win32-x64-msvc.nodez

最后在taro的github里面找到了一样的问题

原因

taro-doctor.win32-x64-msvc.node 是一个 Node 原生模块
在 Windows 上运行时,需要系统里有对应的 VC++ 运行库

如果系统里没有,或者版本不完整,Node 加载这个 .node 文件时就会直接报:

The specified module could not be found

即使文件本身是存在的。

解决方法

去微软官网下载并安装最新版的 Microsoft Visual C++ Redistributable

链接:learn.microsoft.com/zh-cn/cpp/w…

装完之后 重启了一次系统,再启动项目:

npm run dev

没有再报错,可以正常运行了。

docker+nginx部署

nginx.conf



worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    server {
        listen       80;
        server_name  localhost;
        

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}


Dockerfile


#引入node
FROM node as build
#拷贝当前文件夹到docker中的demo文件夹,demo文件夹自动生成
COPY . ./demo
#定位到demo文件夹
WORKDIR /demo
#设置源
RUN npm config set registry https://registry.npm.taobao.org
#下载
RUN npm install
#打包
RUN npm run build
#引入nginx
FROM nginx
#复制项目的nginx.conf到 /etc/nginx目录,并修改为文件名为nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
#复制生成的dist文件夹到nginx的html目录下
COPY --from=build /demo/dist /usr/share/nginx/html
#显式地标明镜像开放端口,一定程度上提供了操作的便利,也提高了 Dockerfile 的可读性和可维护性
EXPOSE 80
#运行nginx
CMD ["nginx","-g","daemon off;"] 


项目根目录运行命令,打包为镜像

docker image build -t vue_nginx_demo .

运行镜像

docker container run -p 8090:80 -d vue_nginx_demo

其他的自动化,使用jenkins或者k8s写部署脚本

前端模块化发展

前端模块化

前言

┌─────────────────────────────────────────────────────────────────────────────┐
│                         知识体系递进关系                                      │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  第一章:为什么需要模块化?                                                  │
│     └── 问题背景、历史演进                                                   │
│            │                                                                │
│            ▼                                                                │
│  第二章:模块化规范有哪些?                                                  │
│     └── CommonJSAMD/UMDESM 语法与特性对比                                │
│            │                                                                │
│            ▼                                                                │
│  第三章:ESM 如何工作?(官方标准深入)                                       │
│     └── 三阶段加载、静态特性、实时绑定、循环依赖                              │
│            │                                                                │
│            ▼                                                                │
│  第四章:为什么需要构建工具?                                                │
│     └── ESM 的局限性、Webpack 的价值定位                                     │
│            │                                                                │
│            ▼                                                                │
│  第五章:Webpack 构建原理                                                   │
│     └── 构建流程、Module/Chunk/Bundle、模块包裹机制                          │
│            │                                                                │
│            ▼                                                                │
│  第六章:Webpack 运行时机制                                                 │
│     └── __webpack_require__、异步加载、JSONP 回调                            │
│            │                                                                │
│            ▼                                                                │
│  第七章:优化策略与最佳实践                                                  │
│     └── Tree Shaking、代码分割、模块输出格式                                 │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

第一章:模块化的历史演进与问题背景

本章解决的问题:为什么 JavaScript 需要模块化?模块化是如何一步步发展的?

1.1 无模块时代的痛点(1995-2009)

JavaScript 诞生之初并没有模块系统,所有代码共享全局作用域:

// a.js
var name = "moduleA";
function helper() { /*...*/ }

// b.js
var name = "moduleB";  // 💥 覆盖了 a.js 的 name!
function helper() { /*...*/ }  // 💥 覆盖了 a.js 的 helper!

// index.html - 必须手动管理加载顺序
<script src="a.js"></script>
<script src="b.js"></script>

核心问题

  1. 命名冲突:全局变量相互覆盖
  2. 依赖管理:手动维护 script 标签顺序
  3. 按需加载:无法实现,所有代码一次性加载

早期解决方案:IIFE + 命名空间

var MyApp = MyApp || {};
MyApp.moduleA = (function () {
  var privateVar = "private";
  return {
    publicMethod: function () { /*...*/ },
  };
})();

1.2 模块化演进时间线

┌─────────────────────────────────────────────────────────────────────────────┐
                     JavaScript 模块化演进史                                   
├─────────────────────────────────────────────────────────────────────────────┤
                                                                             
  1995        2009         2011         2015         2017          现在       |
                                                                       
                                                                       
  无模块    CommonJS     AMD/UMD   ES Modules   Node支持ESM  ESM成为主流   
                                                                       
  全局变量    Node.js      浏览器异步     语言标准       生态统一     构建工具      
  命名冲突   服务端模块      加载需求      静态分析       双格式并存    深度优化      
                                                                             
└─────────────────────────────────────────────────────────────────────────────┘
阶段 时间 规范 背景 特点
1 2009 CommonJS Node.js 诞生,服务端需要模块系统 同步加载,运行时解析
2 2011 AMD/UMD 浏览器需要异步加载(网络延迟) 异步加载,兼容多环境
3 2015 ES Modules JavaScript 语言层面的官方标准 静态分析,编译时确定
4 2017+ 生态统一 Node.js 12+ 原生支持 ESM ESM + CJS 双格式并存

第二章:模块化规范详解

本章解决的问题:三大模块规范(CommonJS、AMD/UMD、ESM)各有什么语法和特性?如何选择?

2.1 规范对比总览

特性 CommonJS AMD UMD ESM
设计目标 服务端 浏览器异步 通用兼容 语言标准
加载时机 运行时 运行时 运行时 编译时
加载方式 同步 异步 依环境 异步(可同步)
导出类型 值拷贝 值拷贝 值拷贝 引用绑定
静态分析
Tree Shaking

2.2 CommonJS 详解

语法

// 导出
module.exports = { a: 1, b: 2 };
// 或
exports.a = 1;
exports.b = 2;

// 导入
const lib = require('./lib');
const { a, b } = require('./lib');

运行时特性(动态)

// ✅ 条件导入
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./prod.js');
} else {
  module.exports = require('./dev.js');
}

// ✅ 动态路径
const name = 'utils';
const utils = require(`./${name}.js`);

// ✅ 循环中导入
['a', 'b', 'c'].forEach(name => {
  modules[name] = require(`./${name}.js`);
});

值拷贝特性

// counter.js
let count = 0;
module.exports = {
  count,
  increment: () => count++,
};

// index.js
const counter = require('./counter');
console.log(counter.count);  // 0
counter.increment();
console.log(counter.count);  // 0 ← 还是 0!值拷贝!

2.3 UMD 详解(通用模块定义)

UMD 的目标是一份代码,多环境运行

(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD 环境 (RequireJS)
    define(['dependency'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS 环境 (Node.js)
    module.exports = factory(require('dependency'));
  } else {
    // 浏览器全局变量
    root.MyLibrary = factory(root.Dependency);
  }
})(typeof self !== 'undefined' ? self : this, function (dependency) {
  return {
    doSomething: function () {
      console.log('Hello from UMD!');
    },
  };
});

环境判断流程

┌──────────────────────┐
│ typeof exports ===   │
│ "object" &&          │─── YES ──→ CommonJS2: module.exports = ...
│ typeof module ===    │
│ "object"             │
└──────────┬───────────┘
           │ NO
           ▼
┌──────────────────────┐
│ typeof define ===    │
│ "function" &&        │─── YES ──→ AMD: define([], factory)
│ define.amd           │
└──────────┬───────────┘
           │ NO
           ▼
┌──────────────────────┐
│ typeof exports ===   │─── YES ──→ CommonJS: exports["name"] = ...
│ "object"             │
└──────────┬───────────┘
           │ NO
           ▼
     浏览器全局变量: root["name"] = ...

2.4 ESM 详解(官方标准)

导出语法

// ─────────── 命名导出 ───────────
export const name = 'ESM';
export function greet() { /*...*/ }
export class Person { /*...*/ }

// 批量导出
const a = 1, b = 2;
export { a, b };

// 重命名导出
export { a as aliasA, b as aliasB };

// ─────────── 默认导出 ───────────
export default function() { /*...*/ }
export default class { /*...*/ }

// ─────────── 聚合导出(re-export)───────────
export { foo, bar } from './other.js';
export * from './utils.js';
export * as utils from './utils.js';  // 命名空间聚合

导入语法

// ─────────── 命名导入 ───────────
import { name, greet } from './module.js';
import { name as aliasName } from './module.js';

// ─────────── 默认导入 ───────────
import MyDefault from './module.js';

// ─────────── 混合导入 ───────────
import MyDefault, { name, greet } from './module.js';

// ─────────── 命名空间导入 ───────────
import * as Module from './module.js';

// ─────────── 副作用导入 ───────────
import './polyfill.js';  // 只执行,不导入任何值

// ─────────── 动态导入 ───────────
const module = await import('./module.js');

静态结构限制(与 CommonJS 的关键区别)

// ❌ ESM 不允许 - 动态导入路径
import { foo } from getModulePath();  // SyntaxError

// ❌ ESM 不允许 - 条件导入
if (condition) {
  import { bar } from './bar.js';  // SyntaxError
}

// ✅ CommonJS 允许 - 完全动态
const mod = require(getModulePath());
if (condition) {
  const bar = require('./bar.js');
}

第三章:ESM 工作原理深入

本章解决的问题:ESM 作为官方标准,它的加载机制是什么?静态特性和实时绑定是如何实现的?

3.1 ESM 三阶段加载过程

ESM 的加载过程分为三个完全独立的阶段:

┌─────────────────────────────────────────────────────────────────┐
│                        时机划分                                  │
├──────────────────┬──────────────────────────────────────────────┤
│                  │                                              │
│   编译时/加载时   │  ① 构建 (Construction) - 解析、发现依赖      │
│   (静态分析)     │  ② 实例化 (Instantiation) - 分配内存、连接绑定│
│                  │                                              │
├──────────────────┼──────────────────────────────────────────────┤
│                  │                                              │
│   运行时         │  ③ 求值 (Evaluation) - 执行代码、填充导出值   │
│   (代码执行)     │                                              │
│                  │                                              │
└──────────────────┴──────────────────────────────────────────────┘
3.1.1 构建阶段 (Construction)
┌─────────────────────────────────────────────────────────────┐
│                      构建阶段                                │
├─────────────────────────────────────────────────────────────┤
│  1. 解析模块说明符 (Module Specifier)                        │
│     './utils.js''file:///project/utils.js'              │
│                                                             │
│  2. 获取模块文件 (Fetch)                                     │
│     - 浏览器: HTTP 请求                                      │
│     - Node.js: 文件系统读取                                  │
│                                                             │
│  3. 解析为模块记录 (Module Record)                           │
│     - 静态分析 import/export 语句                            │
│     - 不执行任何代码                                         │
│     - 构建模块依赖图                                         │
└─────────────────────────────────────────────────────────────┘

关键点

  • 每个模块只会被解析一次,结果缓存在 Module Map
  • 所有依赖通过深度优先遍历被发现和加载
  • 此阶段完全是静态分析,不执行任何 JavaScript 代码
// 模块记录 (Module Record) 的简化结构
{
  [[RequestedModules]]: ['./dep1.js', './dep2.js'],  // 依赖列表
  [[ImportEntries]]: [...],   // 导入条目
  [[ExportEntries]]: [...],   // 导出条目
  [[Status]]: 'unlinked',     // 模块状态
  [[EvaluationError]]: null   // 执行错误
}
3.1.2 实例化阶段 (Instantiation / Linking)
┌─────────────────────────────────────────────────────────────┐
│                      实例化阶段                              │
├─────────────────────────────────────────────────────────────┤
│  1. 为每个模块分配内存空间 (Module Environment Record)        │
│                                                             │
│  2. 创建导出绑定 (Export Bindings)                           │
│     - 在内存中为所有 export 创建"槽位"                        │
│     - 此时槽位未初始化 (uninitialized)                       │
│                                                             │
│  3. 连接导入导出 (Linking)                                   │
│     - import 直接指向 export 的内存地址                      │
│     - 这就是"实时绑定" (Live Binding)                        │
└─────────────────────────────────────────────────────────────┘
3.1.3 求值阶段 (Evaluation)
┌─────────────────────────────────────────────────────────────┐
│                      求值阶段                                │
├─────────────────────────────────────────────────────────────┤
│  1. 按照深度优先、后序遍历的顺序执行模块代码                    │
│     (先执行依赖模块,再执行当前模块)                          │
│                                                             │
│  2. 填充导出槽位的实际值                                      │
│                                                             │
│  3. 每个模块只执行一次,结果被缓存                            │
└─────────────────────────────────────────────────────────────┘

执行顺序示例

// main.js
import { b } from './b.js';
import { a } from './a.js';
console.log('main');

// a.js
console.log('a');
export const a = 'A';

// b.js
import { a } from './a.js';
console.log('b');
export const b = 'B';

// 执行顺序: a → b → main
// 输出: 'a', 'b', 'main'

3.2 静态结构特性

静态分析:在代码执行前(编译阶段),仅通过分析代码文本结构,就能确定所有模块依赖关系。

┌─────────────────────────────────────────────────────────────────┐
│                     静态 vs 动态                                 │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ESM 静态分析(编译时)                                          │
│  ─────────────────────                                           │
│  import { add } from './math.js';                                │
│       ↓                                                          │
│  编译器读取代码文本 → 看到 import 语句 → 知道依赖 math.js        │
│       ↓                                                          │
│  无需执行代码,100% 确定依赖关系                                 │
│                                                                  │
│  ─────────────────────────────────────────────────────────────   │
│                                                                  │
│  CommonJS 动态分析(运行时)                                     │
│  ─────────────────────────                                       │
│  const math = require(getPath());                                │
│       ↓                                                          │
│  必须执行 getPath() 才知道路径是什么                             │
│       ↓                                                          │
│  编译时无法确定依赖!                                            │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

静态结构的优势

┌─────────────────────────────────────────────────────────────┐
│                   静态结构的优势                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. Tree Shaking (摇树优化)                                  │
│     ├── 在构建时分析哪些导出被使用                           │
│     └── 移除未使用的代码 (Dead Code Elimination)             │
│                                                             │
│  2. 更快的查找                                               │
│     ├── 变量查找在编译时确定                                 │
│     └── 无需运行时的动态查找                                 │
│                                                             │
│  3. 类型检查                                                 │
│     ├── TypeScript 可在编译时验证导入                        │
│     └── IDE 提供准确的自动补全                               │
│                                                             │
│  4. 循环依赖处理更可靠                                       │
│     └── 静态分析可以提前发现问题                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3.3 实时绑定 (Live Binding)

ESM 最重要的特性之一,与 CommonJS 的值拷贝形成鲜明对比:

// ============ ESM: 实时绑定 ============
// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
console.log(count);  // 0
increment();
console.log(count);  // 1 ← 看到了变化!

// ============ CommonJS: 值拷贝 ============
// counter.cjs
let count = 0;
module.exports = {
  count,
  increment() { count++; }
};

// main.cjs
const { count, increment } = require('./counter.cjs');
console.log(count);  // 0
increment();
console.log(count);  // 0 ← 还是 0!(拷贝的值)

绑定原理图解

┌────────────────── ESM 实时绑定 ──────────────────┐
                                                  
   导出模块内存                 导入模块          
   ┌──────────────┐           ┌──────────────┐   
    count: [ref]─┼───────────┼─► count         
                                            
                                            
         ┌───┐                               
          0      同一内存                   
         └───┘                               
   └──────────────┘           └──────────────┘   
                                                  
└──────────────────────────────────────────────────┘

┌────────────────── CJS 值拷贝 ───────────────────┐
                                                  
   导出模块                     导入模块          
   ┌──────────────┐           ┌──────────────┐   
    count: ───┐              count: ───┐     
                  拷贝操作                  
                  ═══════►                 
           ┌───┐                    ┌───┐    
            0                      0     
           └───┘                    └───┘    
   └──────────────┘           └──────────────┘   
                                                  
      独立内存                    独立内存        
└──────────────────────────────────────────────────┘

导入是只读的

// module.js
export let value = 1;

// main.js
import { value } from './module.js';
value = 2;  // TypeError: Assignment to constant variable
// 导入的绑定是只读的!

// 但可以修改导入对象的属性
import { obj } from './module.js';
obj.prop = 'new';  // ✅ 这是允许的

3.4 循环依赖处理

使用 const/let 声明 —— 抛出 ReferenceError
// a.mjs
console.log('a.mjs 开始执行');
import { b } from './b.mjs';
console.log('在 a.mjs 中, b =', b);
export const a = 'a 的值';

// b.mjs
console.log('b.mjs 开始执行');
import { a } from './a.mjs';
console.log('在 b.mjs 中, a =', a);  // ❌ ReferenceError!
export const b = 'b 的值';

// 执行 node a.mjs
// 输出:
// b.mjs 开始执行
// ReferenceError: Cannot access 'a' before initialization
//
// 原因:const/let 存在暂时性死区 (TDZ),在初始化前访问会报错
使用 var 声明 —— 得到 undefined
// a.mjs
import { b } from './b.mjs';
export var a = 'a 的值';  // 使用 var

// b.mjs
import { a } from './a.mjs';
console.log('在 b.mjs 中, a =', a);  // undefined (var 会提升)
export var b = 'b 的值';
使用函数声明 —— 正常工作
// a.mjs
import { b } from './b.mjs';
console.log('在 a.mjs 中, b() =', b());
export function a() { return 'a 的值'; }  // 函数声明会提升

// b.mjs
import { a } from './a.mjs';
console.log('在 b.mjs 中, a() =', a());  // ✅ 正常工作!
export function b() { return 'b 的值'; }

循环依赖执行流程

执行 a.mjs:
    │
    ▼
┌─────────────────────────────────────────────────────────────┐
│ 1. 解析 a.mjs,发现依赖 b.mjs                                │
│ 2. 解析 b.mjs,发现依赖 a.mjs (循环!)                       │
│ 3. a.mjs 已在处理中,跳过 (使用未初始化的绑定)                │
│ 4. 执行 b.mjs,此时 a 的导出槽位:                           │
│    - const/let: 处于 TDZ → 访问抛出 ReferenceError          │
│    - var: 已提升但未赋值 → undefined                        │
│    - function: 完整提升 → 可正常调用                        │
│ 5. b.mjs 执行完毕(如果没有报错),b 的导出槽位被填充        │
│ 6. 回到 a.mjs 继续执行                                       │
│ 7. a.mjs 执行完毕,a 的导出槽位被填充                        │
└─────────────────────────────────────────────────────────────┘

避免循环依赖问题的方法

// ✅ 方法1: 使用函数延迟访问
// a.mjs
import { getB } from './b.mjs';
export const a = 'a';
export function getA() { return a; }
console.log(getB());  // 在函数调用时,b 已初始化

// b.mjs
import { getA } from './a.mjs';
export const b = 'b';
export function getB() { return b; }

// ✅ 方法2: 将共享状态提取到第三个模块
// shared.mjs
export const shared = { a: null, b: null };

// a.mjs
import { shared } from './shared.mjs';
shared.a = 'a';

// b.mjs
import { shared } from './shared.mjs';
shared.b = 'b';

3.5 ESM 在不同环境中的实现

浏览器中的 ESM
<!-- 使用 type="module" -->
<script type="module">
  import { func } from './module.js';
  func();
</script>

<!-- 外部模块 -->
<script type="module" src="./main.js"></script>

<!-- 模块特性 -->
<script type="module">
  // 1. 默认 defer - 不阻塞 HTML 解析
  // 2. 默认 strict mode
  // 3. 顶层 this 是 undefined
  // 4. 支持顶层 await (ES2022)
  // 5. 同源策略 - 跨域需要 CORS
</script>

<!-- 兼容降级 -->
<script type="module" src="modern.js"></script>
<script nomodule src="legacy.js"></script>
Node.js 中的 ESM
// 方式1: 使用 .mjs 扩展名
// utils.mjs
export function helper() {}

// 方式2: package.json 中设置 "type": "module"
// package.json
{
  "type": "module"  // 所有 .js 文件视为 ESM
}

// 方式3: .cjs 扩展名强制 CommonJS
// legacy.cjs - 即使 type: module,也是 CommonJS

// Node.js 特有的导入方式
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';

// import.meta 对象
console.log(import.meta.url);       // file:///path/to/module.mjs
console.log(import.meta.dirname);   // /path/to (Node 20.11+)
console.log(import.meta.filename);  // /path/to/module.mjs

第四章:为什么需要构建工具?

本章解决的问题:ESM 已经是官方标准了,为什么还需要 Webpack 这样的构建工具?

4.1 前端工程化的痛点

虽然 ESM 解决了模块化的语法标准问题,但前端开发还面临更多挑战:

┌─────────────────────────────────────────────────────────────────────────┐
│                        前端模块化的痛点                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 模块规范混乱        2. 浏览器兼容性       3. 资源类型多样            │
│  ┌─────────────┐       ┌─────────────┐       ┌─────────────┐           │
│  │ CommonJS    │       │ ES Module   │       │ .js .css    │           │
│  │ AMD / UMD   │       │ 浏览器支持  │       │ .png .svg   │           │
│  │ ES Module   │       │ 有限        │       │ .json .wasm │           │
│  └─────────────┘       └─────────────┘       └─────────────┘           │
│                                                                         │
│  4. 开发效率            5. 性能优化                                      │
│  ┌─────────────┐       ┌─────────────┐                                  │
│  │ 热更新      │       │ 代码分割    │                                  │
│  │ Source Map  │       │ Tree Shaking│                                  │
│  └─────────────┘       │ 压缩混淆    │                                  │
│                        └─────────────┘                                  │
│                                                                         │
│                              ↓                                          │
│                     ┌─────────────────┐                                 │
│                     │    Webpack      │                                 │
│                     │  统一解决方案    │                                 │
│                     └─────────────────┘                                 │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

4.2 Webpack 的价值定位

Webpack 本质:一个静态模块打包器,以 entry 为起点,递归构建依赖图,将所有模块打包成浏览器可运行的 Bundle。

问题 ESM 原生能力 Webpack 解决方案
模块规范混乱 只支持 ESM 统一处理 CJS/AMD/ESM
浏览器兼容 需要现代浏览器 转译为兼容代码
非 JS 资源 不支持 Loader 处理任意类型
性能优化 Tree Shaking、代码分割
开发体验 HMR、Source Map

4.3 构建工具链全景

┌─────────────────────────────────────────────────────────────────────────────┐
│                         现代构建工具处理流程                                 │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  源代码 (ES6+ / ESM)                                                         │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                         Babel-loader                                 │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  • 转换 ES6+ 语法(async/await, class, 箭头函数等)                 │    │
│  │  • 保留 import/export 语法(modules: false)← 关键!                │    │
│  │  • 转换 JSXTypeScript 等                                          │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           Webpack                                    │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  1. 解析入口文件,构建依赖图                                        │    │
│  │  2.import/export 转换为 __webpack_require__                     │    │
│  │  3. 标记未使用的导出(usedExports)                                 │    │
│  │  4. 代码分割(动态 import → 单独 chunk)                            │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  ┌─────────────────────────────────────────────────────────────────────┐    │
│  │                           Terser                                     │    │
│  │  ─────────────────────────────────────────────────────────────────  │    │
│  │  • 删除标记为未使用的代码(Tree Shaking 完成)                      │    │
│  │  • 压缩变量名、移除空白、内联简单函数                               │    │
│  │  • 删除 console.log(可配置)                                       │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│        │                                                                     │
│        ▼                                                                     │
│  最终 Bundle(优化后的 ES5 代码)                                            │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

第五章:Webpack 构建原理

本章解决的问题:Webpack 内部是如何工作的?构建流程是怎样的?

5.1 三大核心角色

角色 职责 类比
Compiler 编译器,全局单例,贯穿整个生命周期 总指挥
Compilation 单次编译过程,包含模块、依赖、Chunk 等 一次构建任务
Module 文件的抽象,每个源文件对应一个 Module 构建的最小单位

5.2 核心概念:Module、Chunk、Bundle

概念 定义 生命阶段
Module 源文件的抽象,Webpack 处理的最小单位 Make 阶段
Chunk 多个 Module 的集合,打包的中间态 Seal 阶段
Bundle Chunk 经过处理后输出的最终文件 Emit 阶段
┌─────────────────────────────────────────────────────────────────────────┐
│                       三者关系:一对多                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   Module (源文件)              Chunk (中间态)             Bundle (产物) │
│   ┌─────────┐                                                           │
│   │  a.js   │─┐                                                         │
│   └─────────┘ │              ┌─────────────────┐                        │
│   ┌─────────┐ ├─────────────→│   main chunk    │────────→  main.js     │
│   │  b.js   │─┤              └─────────────────┘                        │
│   └─────────┘ │                                                         │
│   ┌─────────┐─┘                                                         │
│   │  c.css  │                                                           │
│   └─────────┘                                                           │
│                                                                         │
│   ┌─────────┐                ┌─────────────────┐                        │
│   │ lodash  │───────────────→│  vendor chunk   │────────→  vendor.js   │
│   └─────────┘                └─────────────────┘                        │
│                                                                         │
│   ┌─────────┐                ┌─────────────────┐                        │
│   │ lazy.js │───────────────→│   async chunk   │────────→  lazy.js     │
│   └─────────┘                └─────────────────┘                        │
│                                                                         │
│   关系:N Module → 1 Chunk → 1 Bundle                                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Chunk 的三种产生方式

产生方式 触发条件 示例
Entry Chunk 每个 entry 配置 entry: { main: './src/index.js' }
Async Chunk 动态导入 import() import('./lazy.js')
Split Chunk SplitChunks 配置提取公共模块 splitChunks: { chunks: 'all' }

5.3 完整构建流程

┌─────────────────────────────────────────────────────────────────────────────┐
│                          Webpack 构建全流程                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌───────┐ │
│  │Initialize│───→│   Make   │───→│   Seal   │───→│ Optimize │───→│ Emit  │ │
│  │  初始化   │    │  构建    │    │   封装   │    │   优化   │    │ 输出  │ │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └───────┘ │
│       │               │               │               │              │      │
│       ▼               ▼               ▼               ▼              ▼      │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐    ┌───────┐ │
│  │合并配置   │    │从Entry   │    │形成Chunk │    │SplitChunks│   │写入   │ │
│  │创建Compiler│   │递归解析  │    │建立映射  │    │TreeShaking│   │文件   │ │
│  │注册Plugin │    │执行Loader│    │生成代码  │    │压缩混淆  │    │系统   │ │
│  └──────────┘    └──────────┘    └──────────┘    └──────────┘    └───────┘ │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
5.3.1 Initialize 阶段
// 核心任务
// 1. 合并配置(命令行 + 配置文件 + 默认值)
const options = merge(defaultConfig, userConfig, cliConfig);

// 2. 创建 Compiler 实例(全局单例)
const compiler = new Compiler(options);

// 3. 注册所有插件
for (const plugin of options.plugins) {
  plugin.apply(compiler);  // 插件通过 apply 方法注入 hooks
}

// 4. 触发 environment 钩子
compiler.hooks.environment.call();
compiler.hooks.afterEnvironment.call();
5.3.2 Make 阶段(核心:构建 ModuleGraph)
┌─────────────────────────────────────────────────────────────────────────┐
│                           Make 阶段详解                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│   Entry Point                      递归处理流程                          │
│   ┌─────────┐                                                           │
│   │index.js │                                                           │
│   └────┬────┘                                                           │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 1: 创建 Module                                             │  │
│   │  const module = new NormalModule({                               │  │
│   │    request: './src/index.js',                                    │  │
│   │    type: 'javascript/auto',                                      │  │
│   │    loaders: [babel-loader, ...]                                  │  │
│   │  });                                                             │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 2: Loader 转换                                             │  │
│   │  source = runLoaders(loaders, originalSource);                   │  │
│   │                                                                  │  │
│   │  // Loader 链式调用(从右到左)                                   │  │
│   │  // sass-loader → css-loader → style-loader                      │  │
│   │  // ts-loader → babel-loader                                     │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 3: AST 解析,提取依赖                                       │  │
│   │  const ast = parse(source);                                      │  │
│   │                                                                  │  │
│   │  // 识别 import/require 语句                                      │  │
│   │  import utils from './utils';  → HarmonyImportDependency         │  │
│   │  require('./config');          → CommonJsDependency              │  │
│   │  import('./lazy');             → ImportDependency (async)        │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   ┌─────────────────────────────────────────────────────────────────┐  │
│   │  Step 4: 递归处理依赖                                            │  │
│   │  for (const dep of module.dependencies) {                        │  │
│   │    this.handleModuleCreation(dep);  // 递归回到 Step 1           │  │
│   │  }                                                               │  │
│   └─────────────────────────────────────────────────────────────────┘  │
│        │                                                                │
│        ▼                                                                │
│   最终产物:ModuleGraph(依赖关系图)                                    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
5.3.3 Seal 阶段(核心:生成 Chunk)

这是 SplitChunks 生效的阶段

┌─────────────────────────────────────────────────────────────────────────┐
                           Seal 阶段详解                                   
├─────────────────────────────────────────────────────────────────────────┤
                                                                         
   Step 1: 形成初始 Chunk                                                
   ┌─────────────────────────────────────────────────────────────────┐  
     Entry A ──→ Initial Chunk A                                      
                 ├── a.js                                             
                 ├── utils.js (被 A、B 都引用)                         
                 └── lodash   (被 A、B 都引用)                         
                                                                      
     Entry B ──→ Initial Chunk B                                      
                 ├── b.js                                             
                 ├── utils.js (重复!)                                 
                 └── lodash   (重复!)                                 
                                                                      
     import() ──→ Async Chunk                                         
                 └── lazy.js                                          
   └─────────────────────────────────────────────────────────────────┘  
                                                                        
                                                                        
   Step 2: SplitChunks 优化(optimizeChunks 钩子)                        
   ┌─────────────────────────────────────────────────────────────────┐  
     遍历所有 Module,检查:                                           
     ┌───────────────────────────────────────────────────────────┐   
       lodash:                                                     
         - 被引用次数: 2 (Chunk A, B)     minChunks: 1          
         - 模块大小: 70KB                 minSize: 20KB         
         - 来源: node_modules            匹配 test 正则          
          提取到 vendors chunk                                    
     └───────────────────────────────────────────────────────────┘   
     ┌───────────────────────────────────────────────────────────┐   
       utils.js:                                                   
         - 被引用次数: 2 (Chunk A, B)     minChunks: 2          
         - 模块大小: 5KB                 < minSize: 20KB         
          不提取,保留在原 chunk                                  
     └───────────────────────────────────────────────────────────┘   
   └─────────────────────────────────────────────────────────────────┘  
                                                                        
                                                                        
   Step 3: 代码生成                                                       
   ┌─────────────────────────────────────────────────────────────────┐  
     最终 Chunk 结构:                                                 
     Chunk A:  a.js + utils.js                                        
     Chunk B:  b.js + utils.js                                        
     Chunk vendors: lodash                                            
     Chunk async: lazy.js                                             
                                                                      
     为每个 Chunk 生成代码:                                           
     - 包裹 Module 为函数                                             
     - 注入 Runtime 代码                                              
     - 建立 Chunk 间依赖关系                                          
   └─────────────────────────────────────────────────────────────────┘  
                                                                         
└─────────────────────────────────────────────────────────────────────────┘

5.4 Module 包裹机制

为什么要包裹成函数?
┌─────────────────────────────────────────────────────────────────────────┐
│                       Module 包裹的三大目的                               │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  1. 创建独立作用域                                                       │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  每个模块的变量不会污染全局                                     │  │
│     │  var name = 'a.js'var name = 'b.js' 不会冲突               │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  2. 注入模块系统                                                         │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  module        → 当前模块对象                                   │  │
│     │  exports       → 导出对象 (module.exports 的引用)               │  │
│     │  __webpack_require__ → 引入其他模块的函数                       │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  3. 按需执行 + 缓存                                                      │
│     ┌───────────────────────────────────────────────────────────────┐  │
│     │  函数只有被 require 时才执行(惰性加载)                        │  │
│     │  执行一次后结果被缓存到 installedModules(单例模式)            │  │
│     └───────────────────────────────────────────────────────────────┘  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
包裹前后对比

源代码

// src/utils.js
export const add = (a, b) => a + b;
export const name = 'utils';

编译后(ES Module)

"./src/utils.js": function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  // 标记为 ES Module(用于和 CommonJS 区分)
  __webpack_require__.r(__webpack_exports__);

  // 定义导出(使用 getter,实现 live binding)
  __webpack_require__.d(__webpack_exports__, {
    "add": function() { return add; },
    "name": function() { return name; }
  });

  // 原始代码
  const add = (a, b) => a + b;
  const name = 'utils';
}

5.5 最终 Bundle 结构

// bundle.js
(function(modules) {
  // ========== Runtime 代码 ==========

  // 模块缓存
  var installedModules = {};

  // 核心加载函数
  function __webpack_require__(moduleId) {
    // 检查缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    // 创建新模块
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );

    module.l = true;
    return module.exports;
  }

  // 工具函数
  __webpack_require__.r = function(exports) {
    Object.defineProperty(exports, '__esModule', { value: true });
  };

  __webpack_require__.d = function(exports, definition) {
    for (var key in definition) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key]  // getter 实现 live binding
      });
    }
  };

  // ========== 启动入口 ==========
  return __webpack_require__('./src/index.js');

})({
  // ========== 所有 Module(包裹后)==========
  "./src/index.js": function(module, exports, __webpack_require__) {
    var utils = __webpack_require__("./src/utils.js");
    console.log(utils.add(1, 2));
  },
  "./src/utils.js": function(module, __webpack_exports__, __webpack_require__) {
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, {
      "add": function() { return add; }
    });
    const add = (a, b) => a + b;
  }
});

第六章:Webpack 运行时机制

本章解决的问题:Webpack 打包后的代码在浏览器中是如何运行的?同步和异步加载是如何实现的?

6.1 同步加载:__webpack_require__

┌─────────────────────────────────────────────────────────────────────────┐
│                    __webpack_require__(moduleId) 流程                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│                         require('./utils.js')                           │
│                                   │                                     │
│                                   ▼                                     │
│                    ┌──────────────────────────────┐                     │
│                    │   检查 installedModules      │                     │
│                    │   是否有缓存?                │                     │
│                    └──────────────────────────────┘                     │
│                           │                                             │
│              ┌────────────┴────────────┐                                │
│              │ YES                     │ NO                             │
│              ▼                         ▼                                │
│      ┌─────────────┐         ┌─────────────────────┐                   │
│      │ 直接返回     │         │ 创建 module 对象     │                   │
│      │ 缓存的       │         │ {                   │                   │
│      │ exports     │         │   i: moduleId,      │                   │
│      └─────────────┘         │   l: false,         │                   │
│                              │   exports: {}       │                   │
│                              │ }                   │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ 放入缓存             │                   │
│                              │ installedModules    │                   │
│                              │ [moduleId] = module │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ 执行模块函数         │                   │
│                              │ modules[moduleId]   │                   │
│                              │ .call(...)          │                   │
│                              └─────────────────────┘                   │
│                                        │                                │
│                                        ▼                                │
│                              ┌─────────────────────┐                   │
│                              │ module.l = true     │                   │
│                              │ 返回 module.exports │                   │
│                              └─────────────────────┘                   │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

6.2 异步加载:__webpack_require__.e

动态 import() 会被转换为 __webpack_require__.e

// 源代码
import('./lazy').then(module => {
  module.doSomething();
});

// 编译后
__webpack_require__.e(/* chunkId */ 'lazy')
  .then(__webpack_require__.bind(null, './src/lazy.js'))
  .then(module => {
    module.doSomething();
  });

__webpack_require__.e 实现原理

// Chunk 加载状态
// undefined: 未加载
// [resolve, reject]: 正在加载
// 0: 已加载
var installedChunks = {};

__webpack_require__.e = function(chunkId) {
  var promises = [];

  var installedChunkData = installedChunks[chunkId];

  // 0 表示已加载
  if (installedChunkData !== 0) {
    // 正在加载中,复用 Promise
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // 创建新的 Promise
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // 创建 script 标签
      var script = document.createElement('script');
      script.charset = 'utf-8';
      script.timeout = 120;
      script.src = __webpack_require__.p + chunkId + '.bundle.js';

      document.head.appendChild(script);
    }
  }

  return Promise.all(promises);
};

6.3 异步 Chunk 的注册(JSONP)

// lazy.chunk.js(异步 chunk 文件格式)
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  ["lazy"],  // chunkIds
  {          // modules
    "./src/lazy.js": function(module, exports) {
      exports.doSomething = function() {
        console.log("lazy loaded!");
      };
    }
  }
]);

主 bundle 中的 JSONP 回调注册

// 注册 JSONP 回调
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);

jsonpArray.push = webpackJsonpCallback;

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1];

  // 将新模块添加到 modules 对象
  for (moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId];
  }

  // 标记 chunk 已加载,执行 resolve
  for (var i = 0; i < chunkIds.length; i++) {
    var chunkId = chunkIds[i];
    if (installedChunks[chunkId]) {
      installedChunks[chunkId][0]();  // resolve
    }
    installedChunks[chunkId] = 0;     // 标记已加载
  }
}

6.4 运行时流程总览

┌─────────────────────────────────────────────────────────────────────────┐
│                          运行时完整流程                                   │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  页面加载                                                                │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ <script src="main.js"></script>                                 │   │
│  │ <script src="vendor.js"></script>                               │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│                              ▼                                          │
│  初始化 Runtime                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ • 创建 installedModules 缓存                                     │   │
│  │ • 创建 installedChunks 缓存                                      │   │
│  │ • 定义 __webpack_require__ 函数                                  │   │
│  │ • 注册 JSONP 回调                                                │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│                              ▼                                          │
│  执行入口模块                                                            │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │ __webpack_require__("./src/index.js")                           │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                              │                                          │
│               ┌──────────────┴──────────────┐                           │
│               │                             │                           │
│               ▼                             ▼                           │
│  同步依赖                              异步依赖                          │
│  ┌────────────────────┐            ┌────────────────────┐              │
│  │__webpack_require__ │            │__webpack_require__.e│              │
│  │ 从 modules 取      │            │ 创建 script 标签   │              │
│  │ 执行 + 缓存        │            │ 加载 chunk 文件    │              │
│  └────────────────────┘            └────────────────────┘              │
│                                             │                           │
│                                             ▼                           │
│                                    ┌────────────────────┐              │
│                                    │ JSONP 回调         │              │
│                                    │ 注册新 modules     │              │
│                                    │ resolve Promise    │              │
│                                    └────────────────────┘              │
│                                             │                           │
│                                             ▼                           │
│                                    ┌────────────────────┐              │
│                                    │__webpack_require__ │              │
│                                    │ 执行异步模块       │              │
│                                    └────────────────────┘              │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

第七章:优化策略与最佳实践

本章解决的问题:如何利用 ESM 的静态特性和 Webpack 的优化能力,实现最佳的构建效果?

7.1 Tree Shaking 原理

Tree Shaking:移除 JavaScript 中未使用的代码(Dead Code Elimination)

┌─────────────────────────────────────────────────────────────────┐
│                Tree Shaking 工作原理                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  源代码(math.js)                                               │
│  ─────────────                                                   │
│  export const add = (a, b) => a + b;      // 被使用              │export const sub = (a, b) => a - b;      // 未使用              │export const mul = (a, b) => a * b;      // 未使用              │
│                                                                  │
│  使用方(index.js)                                              │
│  ─────────────                                                   │
│  import { add } from './math.js';                                │
│  console.log(add(1, 2));                                         │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  阶段 1: Webpack 标记(Mark Phase)                      │    │
│  │  ─────────────────────────────────                       │    │
│  │  分析 import 语句 → 标记 add 为"已使用"                  │    │
│  │  sub, mul 没有被任何 import → 标记为"未使用"             │    │
│  │                                                          │    │
│  │  生成代码(带标记):                                    │    │
│  │  __webpack_require__.d(exports, { add: () => add });     │    │
│  │  /* unused harmony export sub */                         │    │
│  │  /* unused harmony export mul */                         │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │  阶段 2: Terser 删除(Sweep Phase)                      │    │
│  │  ─────────────────────────────────                       │    │
│  │  识别 unused 标记 → 这些变量没有被引用                   │    │
│  │  安全删除 sub 和 mul 的定义                              │    │
│  └─────────────────────────────────────────────────────────┘    │
│                                                                  │
│         │                                                        │
│         ▼                                                        │
│                                                                  │
│  最终 Bundle                                                     │
│  ──────────                                                      │
│  const add = (a, b) => a + b;  // 只保留 add!                   │console.log(add(1, 2));                                         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Tree Shaking 生效条件

条件 说明 原因
使用 ESM import/export 语法 静态分析的前提
production 模式 usedExports: true 启用标记功能
声明无副作用 sideEffects: false 告诉打包工具可安全删除
启用压缩 Terser/UglifyJS 实际删除代码
避免整体导入 { a } 不用 * as 明确使用范围

7.2 sideEffects 配置

什么是副作用?

// ─────────── 有副作用 ───────────
// polyfill.js - 修改全局对象
Array.prototype.myMethod = function() {};

// analytics.js - 执行时发送请求
fetch('/api/track?page=home');

// styles.css - 影响页面样式
.button { color: red; }

// ─────────── 无副作用 ───────────
// utils.js - 纯函数,不影响外部
export const add = (a, b) => a + b;
export const format = (str) => str.trim();

package.json 配置

{
  "name": "my-library",
  "sideEffects": false
}
{
  "name": "my-library",
  "sideEffects": [
    "*.css",
    "*.scss",
    "./src/polyfill.js"
  ]
}

7.3 代码分割策略(SplitChunks)

默认配置详解

optimization: {
  splitChunks: {
    // 对哪些 chunk 生效
    // 'async': 只处理异步 chunk(默认)
    // 'initial': 只处理入口 chunk
    // 'all': 处理所有 chunk(推荐)
    chunks: 'async',

    // 分割阈值
    minSize: 20000,           // 最小 20KB 才分割
    minRemainingSize: 0,      // 分割后剩余最小体积
    minChunks: 1,             // 最少被引用 1 次

    // 并行请求限制
    maxAsyncRequests: 30,     // 按需加载时最大并行请求数
    maxInitialRequests: 30,   // 入口点最大并行请求数

    // 缓存组(核心配置)
    cacheGroups: {
      // 第三方库
      defaultVendors: {
        test: /[\\/]node_modules[\\/]/,
        priority: -10,
        reuseExistingChunk: true,
      },
      // 公共模块
      default: {
        minChunks: 2,
        priority: -20,
        reuseExistingChunk: true,
      },
    },
  },
}

推荐配置

optimization: {
  splitChunks: {
    chunks: 'all',
    cacheGroups: {
      // React 全家桶单独打包
      react: {
        test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
        name: 'react-vendor',
        priority: 20,
        chunks: 'all',
      },
      // 其他第三方库
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        priority: 10,
        chunks: 'all',
      },
      // 业务公共模块
      common: {
        name: 'common',
        minChunks: 2,
        priority: 5,
        reuseExistingChunk: true,
      },
    },
  },
  // 运行时代码单独分离(利于缓存)
  runtimeChunk: {
    name: 'runtime',
  },
}

7.4 模块输出格式

格式 导出语法 使用场景 Tree Shaking
var var MyLib = ... 全局变量
commonjs exports.MyLib = ... Node.js
commonjs2 module.exports = ... Node.js
amd define([], factory) RequireJS
umd 通用模块定义 多环境兼容
module export { ... } ES Module

多格式输出配置

// webpack.config.js - 同时输出多种格式
module.exports = [
  // ESM 版本(现代环境,支持 Tree Shaking)
  {
    entry: './src/index.js',
    experiments: { outputModule: true },
    output: {
      filename: 'my-library.esm.js',
      library: { type: 'module' },
    },
  },
  // CommonJS 版本(Node.js)
  {
    entry: './src/index.js',
    output: {
      filename: 'my-library.cjs.js',
      library: { type: 'commonjs2' },
    },
  },
  // UMD 版本(浏览器直接引用)
  {
    entry: './src/index.js',
    output: {
      filename: 'my-library.umd.js',
      library: { name: 'MyLibrary', type: 'umd' },
      globalObject: 'this',
    },
  },
];

对应 package.json

{
  "name": "my-library",
  "main": "./dist/my-library.cjs.js",
  "module": "./dist/my-library.esm.js",
  "browser": "./dist/my-library.umd.js",
  "exports": {
    ".": {
      "import": "./dist/my-library.esm.js",
      "require": "./dist/my-library.cjs.js"
    }
  },
  "sideEffects": false
}

7.5 最佳实践汇总

模块规范选择
┌────────────────────────────────────────────────────────────────┐
│                     模块规范选择指南                            │
├────────────────────────────────────────────────────────────────┤
│                                                                 │
│   ┌─────────────────┐                                          │
│   │ 新项目?        │                                          │
│   └────────┬────────┘                                          │
│            │                                                    │
│      是 ←──┴──→ 否                                              │
│      │          │                                               │
│      ▼          ▼                                               │
│   ┌──────┐   ┌─────────────────┐                               │
│   │ ESM  │   │ 需要兼容老环境? │                               │
│   └──────┘   └────────┬────────┘                               │
│                       │                                         │
│                 是 ←──┴──→ 否                                   │
│                 │          │                                    │
│                 ▼          ▼                                    │
│              ┌─────┐    ┌──────┐                                │
│              │ UMD │    │ ESM  │                                │
│              └─────┘    └──────┘                                │
│                                                                 │
│   ─────────────────────────────────────────────────────────    │
│                                                                 │
│   发布 npm 包? ────────────→ 同时提供 ESM + CJS                │
│                                                                 │
│   CDN 直接引用? ───────────→ UMD                               │
│                                                                 │
│   需要 Tree Shaking? ──────→ ESM(必须)                       │
│                                                                 │
└────────────────────────────────────────────────────────────────┘
导入最佳实践
// ❌ 不推荐:导入整个库
import _ from 'lodash';
_.map([1, 2], x => x * 2);

// ✅ 推荐:按需导入
import { map } from 'lodash-es';
map([1, 2], x => x * 2);

// ❌ 不推荐:命名空间导入(阻止 Tree Shaking)
import * as utils from './utils';
utils.format(str);

// ✅ 推荐:具名导入
import { format } from './utils';
format(str);

// ✅ 懒加载:大模块动态导入
const loadChart = async () => {
  const { Chart } = await import('./chart');
  return new Chart();
};
CommonJS 迁移到 ESM
// ─────────── CommonJS ───────────
const fs = require('fs');
const path = require('path');
const { myFunc } = require('./utils');

module.exports = { a, b };
module.exports.c = c;

// ─────────── ESM ───────────
import fs from 'fs';
import path from 'path';
import { myFunc } from './utils.js';  // 注意扩展名!

export { a, b };
export { c };

// __dirname/__filename 替代方案
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

附录:核心概念速查表

A. 模块规范对比

特性 CommonJS ESM
语法 require/exports import/export
加载时机 运行时 编译时
绑定类型 值拷贝 实时绑定
静态分析
Tree Shaking
顶层 await

B. Webpack 构建阶段

阶段 核心任务 关键 Hook
Initialize 合并配置、创建 Compiler、注册 Plugin environment
Make 从 Entry 递归解析,执行 Loader,构建 ModuleGraph make
Seal 生成 Chunk、执行 SplitChunks、生成代码 optimizeChunks
Optimize Tree Shaking、压缩混淆 optimizeTree
Emit 输出 Bundle 文件 emit

C. 运行时机制

机制 说明
__webpack_require__ 同步加载模块,从 modules 对象取并执行
__webpack_require__.e 异步加载 Chunk,动态创建 script 标签
installedModules 模块缓存,避免重复执行
installedChunks Chunk 缓存,避免重复加载
webpackJsonp JSONP 回调,注册异步 Chunk 的模块

D. 知识体系一图总结

┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                              │
│                    JavaScript 模块化知识体系                                 │
│                                                                              │
├──────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│   历史演进                                                                   │
│   ────────                                                                   │
│   全局变量 → CommonJS(Node) → AMD(浏览器) → UMD(通用) → ESM(标准)            │
│                                                                              │
│   核心区别                                                                   │
│   ────────                                                                   │
│   CommonJS: 运行时加载,值拷贝,动态路径 → 无法 Tree Shaking                 │
│   ESM:      编译时分析,引用绑定,静态路径 → 支持 Tree Shaking               │
│                                                                              │
│   ESM 三阶段                                                                 │
│   ──────────                                                                 │
│   构建(解析依赖) → 实例化(分配内存/连接绑定) → 求值(执行代码)                │
│                                                                              │
│   构建工具链                                                                 │
│   ──────────                                                                 │
│   源码(ESM) → Babel(保留ESM,转语法) → Webpack(打包,标记) → Terser(删除,压缩) │
│                                                                              │
│   Webpack 核心                                                               │
│   ────────────                                                               │
│   Module(源文件) → Chunk(中间态) → Bundle(产物)                              │
│   Make(构建ModuleGraph) → Seal(生成Chunk) → Emit(输出文件)                   │
│                                                                              │
│   最佳实践                                                                   │
│   ────────                                                                   │
│   • 新项目首选 ESM                                                           │
│   • npm 包同时提供 ESM + CJS                                                 │
│   • 按需导入,避免 import *                                                  │
│   • 配置 sideEffects: false                                                  │
│   • 大模块使用动态 import() 懒加载                                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

开发一个美观的 VitePress 图片预览插件

前言

笔者维护的 VitePress 博客主题已经集成了非常多的功能,为便于在主题之外复用,因此有计划将其一部分功能分离出来,形成独立的插件。

现在又有AI加持,再已经有通用插件模板前提下,使用AI就能完成95%的插件工作量!

分离的 图片预览插件,效果如下:

组件样式实现参考了 Element Plus Image Viewer

接下来先简单介绍一下用法,再快速讲解核心原理。

插件开发基于之前创建的一个通用模板,vitepress-plugin-slot-inject-template,在模板的基础上,插件95%的代码由 Gemini 3.0 生成。

如何使用

只需要 2 步:

  1. 安装插件
pnpm add vitepress-plugin-image-preview
  1. 配置插件

引入插件在 .vitepress/config.mts VitePress 配置文件中

import { defineConfig } from 'vitepress'
import { ImagePreviewPlugin } from 'vitepress-plugin-image-preview'

export default defineConfig({
  vite: {
    plugins: [
      ImagePreviewPlugin()
    ]
  }
})

实现原理

这里只阐述关键点,细节与之前的公告插件类似,这里不做赘述。

VitePress 默认主题 Layout.vue 组件预设的一些插槽,只需将实现自定义组件注入到对应插槽为止即可。

所有的 slotsVitePress 文档里也有介绍

注入自定义组件

利用插件的 transform 钩子,将我们的 <ImagePreview /> 组件插入到 Layout.vue 的特定插槽位置

图片预览组件我这里使用的是 doc-beforepage-top 两个插槽。

使用 alias 保证引入组件的路径正确映射。

// 仅包含关键代码
const componentName = 'ImagePreview'
const componentFile = `${componentName}.vue`
const aliasComponentFile = `${getDirname()}/components/${componentFile}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 添加alias
    config: () => {
      return {
        resolve: {
          alias: {
            [`./${componentFile}`]: aliasComponentFile
          }
        }
      }
    },
    transform(code, id) {
      // 筛选出 Layout.vue
      if (id.endsWith('vitepress/dist/client/theme-default/Layout.vue')) {
        let transformResult = code

        // 插入组件
        const slots = [options.slots || ['doc-before', 'page-top']].flat()
        for (const slot of slots) {
          const slotPosition = `<slot name="${slot}" />`
          // 添加 ClientOnly 目的是避免组件在SSG的时候被渲染
          transformResult = transformResult.replace(slotPosition, `${slotPosition}\n<ClientOnly><${componentName} /></ClientOnly>`)
        }

        // 导入组件
        const setupPosition = '<script setup lang="ts">'
        transformResult = transformResult.replace(setupPosition, `${setupPosition}\nimport ${componentName} from './${componentFile}'`)
        return transformResult
      }
    },
  }
}

插件配置传递

采用虚拟模块的方式传递配置。

组件中导入配置:

import options from 'virtual:image-preview-options'

插件中处理虚拟模块:

const virtualModuleId = 'virtual:image-preview-options'
const resolvedVirtualModuleId = `\0${virtualModuleId}`
function ImagePreviewPlugin(options = {}) {
  return {
    // 省略其它无关代码...
    resolveId(id) {
      if (id === virtualModuleId) {
        return resolvedVirtualModuleId
      }
    },
    load(this, id) {
      if (id === resolvedVirtualModuleId) {
        return `export default ${stringify(options)}`
      }
    },
  }
}

核心交互实现

图片预览的核心逻辑在于监听图片的点击事件,获取图片列表,并显示预览遮罩。

  1. 事件监听:在 onMounted时,给内容的容器注册点击事件,在点击的时候获取容器中所有的图片元素,然后做后续操作。
onMounted(() => {
  const wrapperId = imagePreviewOptions?.wrapperId || '#VPContent'
  const docDomContainer = document.querySelector(wrapperId)
  docDomContainer?.addEventListener('click', previewImage)
})

function previewImage(e: Event) {
  const target = e.target as HTMLElement
  const currentTarget = e.currentTarget as HTMLElement
  if (target.tagName.toLowerCase() === 'img') {
    const selector = imagePreviewOptions?.selector || '.content-container .main img,.VPPage img'
    const imgs = currentTarget.querySelectorAll<HTMLImageElement>(selector)
    const idx = Array.from(imgs).findIndex(el => el === target)
    const urls = Array.from(imgs).map(el => el.src)
    // 省略其它逻辑
  }
}
  1. 预览组件:参考了 Element Plus 的 图片预览组件的样式与功能,这部分完全由 AI 实现(Gemini 3.0),还原度非常高。

插件模板介绍

在开发插件的过程中,笔者把此类基于 slot 位置注入的插件分离了一个模板 vitepress-plugin-slot-inject-template

有相关诉求的朋友,可以基于此模板,配合 AI 快速的开发各种基于插槽就可以实现的组件能力。

最后

插件完整源码 vitepress-plugin-image-preview

最后再感叹一句,AI 太牛逼了,效率起飞。

欢迎评论区交流&指导。

webpack/vite配置

webpack配置

webpack.base.cjs

// 导入 Node.js 内置的 path 模块,用于处理文件路径
const path = require('path');
// 导入 vue-loader 插件,用于处理 .vue 文件
const { VueLoaderPlugin } = require('vue-loader');
// 导入 HtmlWebpackPlugin,用于生成 HTML 文件
const HtmlWebpackPlugin = require('html-webpack-plugin');
// 导入 unplugin-auto-import 的 webpack 版本,用于自动导入 Vue 组合式 API
const AutoImport = require('unplugin-auto-import/webpack');
// 导入 unplugin-vue-components 的 webpack 版本,用于自动导入 Vue 组件
const Components = require('unplugin-vue-components/webpack');
// 导入 ElementPlusResolver,用于自动导入 Element Plus 组件和样式
const { ElementPlusResolver } = require('unplugin-vue-components/resolvers');

// 导出 webpack 配置对象
module.exports = {
    // 入口文件配置
    entry: './src/main.ts',

    // 输出配置
    output: {
        // 输出目录,使用绝对路径
        path: path.resolve(__dirname, 'dist'),
    },

    // 解析配置
    resolve: {
        // 路径别名配置
        alias: {
            // 将 @ 别名指向 src 目录
            '@': path.resolve(__dirname, 'src'),
        },
        // 自动解析的扩展名列表,这样导入时可以省略扩展名
        extensions: ['.ts', '.tsx', '.js', '.jsx', '.vue', '.json'],
    },

    // 插件配置
    plugins: [
        // 自动导入配置
        AutoImport({
            // 解析器配置,使用 ElementPlusResolver 自动导入 Element Plus
            resolvers: [ElementPlusResolver()],
        }),
        // 自动组件导入配置
        Components({
            // 解析器配置,使用 ElementPlusResolver 自动导入 Element Plus 组件
            resolvers: [ElementPlusResolver()],
        }),
        // VueLoaderPlugin 必须配置,用于编译 Vue 单文件组件
        new VueLoaderPlugin(),
        // HtmlWebpackPlugin 配置,用于生成 HTML 文件
        new HtmlWebpackPlugin({
            // 使用 public/index.html 作为模板
            template: './public/index.html',
        }),
    ],

    // 模块配置
    module: {
        // 规则配置,用于处理不同类型的文件
        rules: [
            // 处理 .vue 文件的规则
            {
                // 匹配 .vue 文件
                test: /\.vue$/,
                // 使用 vue-loader 处理
                loader: 'vue-loader',
                // vue-loader 选项配置
                options: {
                    // 编译器选项
                    compilerOptions: {
                        // 不保留空格,减小文件体积
                        preserveWhitespace: false,
                    },
                },
            },
            // 处理 .ts 文件的规则
            {
                // 匹配 .ts 文件
                test: /\.ts$/,
                // 排除 node_modules 目录
                exclude: /node_modules/,
                // 使用多个 loader 处理
                use: [
                    // 首先使用 babel-loader 处理
                    {
                        loader: 'babel-loader',
                        // babel-loader 选项
                        options: {
                            // 使用 @babel/preset-env 预设,将 ES6+ 转换为 ES5
                            presets: ['@babel/preset-env'],
                            // 使用 @babel/plugin-transform-runtime 插件,避免重复注入 helper 函数
                            plugins: ['@babel/plugin-transform-runtime'],
                        },
                    },
                    // 然后使用 ts-loader 处理
                    {
                        loader: 'ts-loader',
                        // ts-loader 选项
                        options: {
                            // 为 .vue 文件添加 .ts 后缀,以便 ts-loader 处理
                            appendTsSuffixTo: [/\.vue$/],
                            // 只进行转译,不进行类型检查,提高构建速度
                            transpileOnly: true,
                        },
                    },
                ],
            },
            // 处理 .js 文件的规则
            {
                // 匹配 .js 文件
                test: /\.js$/,
                // 排除 node_modules 目录
                exclude: /node_modules/,
                // 使用 babel-loader 处理
                use: {
                    loader: 'babel-loader',
                    // babel-loader 选项
                    options: {
                        // 使用 @babel/preset-env 预设,将 ES6+ 转换为 ES5
                        presets: ['@babel/preset-env'],
                        // 使用 @babel/plugin-transform-runtime 插件,避免重复注入 helper 函数
                        plugins: ['@babel/plugin-transform-runtime'],
                    },
                },
            },
            // 处理 .css 文件的规则
            {
                // 匹配 .css 文件
                test: /\.css$/,
                // 使用 style-loader 和 css-loader 处理
                // 执行顺序:从右到左,先使用 css-loader 解析 CSS,再使用 style-loader 将 CSS 插入到页面
                use: ['style-loader', 'css-loader'],
            },
            // 处理 .scss 和 .sass 文件的规则
            {
                // 匹配 .scss 和 .sass 文件
                test: /\.s[ac]ss$/i,
                // 使用 style-loader、css-loader 和 sass-loader 处理
                // 执行顺序:从右到左,先使用 sass-loader 编译 Sass,再使用 css-loader 解析 CSS,最后使用 style-loader 将 CSS 插入到页面
                use: ['style-loader', 'css-loader', 'sass-loader'],
            },
            // 处理图片文件的规则
            {
                // 匹配 png、jpg、jpeg、gif 和 webp 格式的图片文件
                test: /\.(png|jpe?g|gif|webp)$/i,
                // 使用 file-loader 处理,将图片文件复制到输出目录并返回文件路径
                use: 'file-loader',
            },
        ],
    },
};

webpack.dev.cjs

// 导入 webpack-merge 模块,用于合并 webpack 配置
const { merge } = require('webpack-merge');
// 导入基础配置文件
const baseConfig = require('./webpack.base.cjs');
// 导入 Node.js 内置的 path 模块,用于处理文件路径
const path = require('path');

// 合并基础配置和开发环境配置
module.exports = merge(baseConfig, {
    // 模式设置为开发环境
    mode: 'development',

    // 输出配置
    output: {
        // 输出文件名,开发环境使用简单的 bundle.js
        filename: 'bundle.js',
    },

    // 开发工具配置,用于生成 source map
    // eval-cheap-module-source-map 能在开发时提供较好的性能和调试体验
    devtool: 'eval-cheap-module-source-map',

    // 开发服务器配置
    devServer: {
        // 静态文件目录配置
        static: {
            // 指定 public 目录为静态文件目录
            directory: path.join(__dirname, 'public'),
        },
        // 是否启用 gzip 压缩
        compress: true,
        // 开发服务器端口
        port: 8000,
        // 是否启用热模块替换(HMR)
        hot: true,
        // 构建完成后是否自动打开浏览器
        open: true,
        // 客户端配置
        client: {
            // 错误和警告的覆盖层配置
            overlay: {
                // 显示错误覆盖层
                errors: true,
                // 不显示警告覆盖层
                warnings: false,
            },
        },
        // 支持 SPA 路由,所有 404 请求都会返回 index.html
        historyApiFallback: true,
        // 代理配置,用于将 API 请求代理到后端服务器
        proxy: [
            {
                // 匹配需要代理的路径
                context: ['/test'],
                // 代理目标地址
                target: 'http://localhost:3000',
                // 是否验证 SSL 证书
                secure: false,
                // 是否修改请求头中的 Origin
                changeOrigin: true,
                // 路径重写配置(当前已注释)
                // pathRewrite: {
                //     '^/api': ''
                // }
            },
        ],
    },

    // 优化配置
    optimization: {
        // 启用 tree shaking,只打包使用到的代码
        usedExports: true,
    },
});

webpack.prod.cjs

// 导入 webpack-merge 模块,用于合并 webpack 配置
const { merge } = require('webpack-merge');
// 导入基础配置文件
const baseConfig = require('./webpack.base.cjs');
// 导入 TerserPlugin,用于压缩 JavaScript 代码
const TerserPlugin = require('terser-webpack-plugin');
// 导入 CssMinimizerPlugin,用于压缩 CSS 代码
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
// 导入 BundleAnalyzerPlugin,用于分析打包后的文件结构和大小
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

// 合并基础配置和生产环境配置
module.exports = merge(baseConfig, {
    // 模式设置为生产环境
    mode: 'production',

    // 输出配置
    output: {
        // 输出文件名,使用 contenthash 确保文件内容变化时文件名变化,有利于缓存
        filename: 'bundle.[contenthash].js',
        // 构建前清理输出目录
        clean: true,
    },
    devtool: 'hidden-source-map',
    // 优化配置
    optimization: {
        // 启用代码压缩
        minimize: true,
        // 配置压缩器
        minimizer: [
            // JavaScript 压缩器配置
            new TerserPlugin({
                // Terser 配置选项
                terserOptions: {
                    // 压缩配置
                    compress: {
                        // 删除所有 console 语句
                        drop_console: true,
                        // 删除所有 debugger 语句
                        drop_debugger: true,
                    },
                },
            }),
            // CSS 压缩器
            new CssMinimizerPlugin(),
            // 打包分析工具配置
            new BundleAnalyzerPlugin({
                // 分析模式设置为静态文件
                analyzerMode: 'static',
                // 不自动打开分析报告
                openAnalyzer: false,
            }),
        ],
        // 代码分割配置
        splitChunks: {
            // 对所有类型的 chunk 进行拆分(包括异步和同步)
            chunks: 'all',
            // 控制生成 chunk 的最小大小(20KB)
            minSize: 20000,
            // 剩余大小的最小值,确保拆分后剩余部分不会过小
            minRemainingSize: 0,
            // 模块被引用的最小次数
            minChunks: 1,
            // 异步加载时的最大请求数
            maxAsyncRequests: 30,
            // 初始加载时的最大请求数
            maxInitialRequests: 30,
            // 强制执行大小阈值(50KB),超过此值的 chunk 会被强制拆分
            enforceSizeThreshold: 50000,
            // 缓存组配置,用于将不同类型的模块分割到不同的 chunk 中
            cacheGroups: {
                // Element Plus 组件库缓存组
                elementPlus: {
                    // 输出文件名
                    name: 'element-plus',
                    // 匹配 Element Plus 模块
                    test: /[\/]node_modules[\/]element-plus[\/]/,
                    // 优先级,数字越大优先级越高
                    priority: 20,
                    // 对所有类型的 chunk 生效
                    chunks: 'all',
                    // 重用已存在的 chunk,避免重复打包
                    reuseExistingChunk: true,
                },
                // Vue Router 路由库缓存组
                vueRouter: {
                    name: 'vue-router',
                    test: /[\/]node_modules[\/]vue-router[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Pinia 状态管理库缓存组
                pinia: {
                    name: 'pinia',
                    test: /[\/]node_modules[\/]pinia[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Axios HTTP 客户端库缓存组
                axios: {
                    name: 'axios',
                    test: /[\/]node_modules[\/]axios[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // Vue 核心库缓存组
                vue: {
                    name: 'vue',
                    test: /[\/]node_modules[\/]vue[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // VueUse 工具库缓存组
                vueUse: {
                    name: 'vue-use',
                    test: /[\/]node_modules[\/]@vueuse[\/]/,
                    priority: 20,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // 公共代码缓存组
                common: {
                    name: 'common',
                    // 至少被引用 2 次的模块才会被打包到这个 chunk
                    minChunks: 2,
                    priority: 5,
                    chunks: 'all',
                    reuseExistingChunk: true,
                },
                // 其他第三方依赖缓存组
                vendors: {
                    name: 'vendors',
                    // 匹配所有 node_modules 中的模块
                    test: /[\/]node_modules[\/]/,
                    priority: 10,
                    // 只对初始加载的 chunk 生效
                    chunks: 'initial',
                    reuseExistingChunk: true,
                    // 强制执行缓存组,即使不符合其他条件
                    enforce: true,
                },
            },
        },
        // 运行时代码分割配置
        runtimeChunk: {
            // 运行时代码的输出文件名
            name: 'runtime',
        },
    },
});

vite配置

vite.base.ts

// 导入Vite的defineConfig函数,用于定义Vite配置
import { defineConfig } from 'vite';
// 导入Vue插件,用于支持Vue组件
import vue from '@vitejs/plugin-vue';
// 导入自动导入插件,用于自动导入Vue API和其他库
import AutoImport from 'unplugin-auto-import/vite';
// 导入组件自动注册插件,用于自动注册Vue组件
import Components from 'unplugin-vue-components/vite';
// 导入Element Plus解析器,用于自动导入Element Plus组件和API
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
// 导入Node.js的path模块,用于处理文件路径
import path from 'path';

// 导出公共配置
export default defineConfig({
    // 设置项目基础路径,使用相对路径
    base: './',

    // 配置Vite插件
    plugins: [
        // 使用Vue插件
        vue(),

        // 配置自动导入插件
        AutoImport({
            // 使用Element Plus解析器
            resolvers: [ElementPlusResolver()],
            // 生成类型声明文件
            dts: true,
            // 自动导入的库
            imports: ['vue', 'vue-router', 'pinia'],
        }),

        // 配置组件自动注册插件
        Components({
            // 使用Element Plus解析器
            resolvers: [ElementPlusResolver()],
            // 生成类型声明文件
            dts: true,
            // 组件所在目录
            dirs: ['./src/components'],
        }),
    ],

    // 配置模块解析
    resolve: {
        // 配置路径别名
        alias: {
            // 将@符号指向src目录
            '@': path.resolve(__dirname, './src'),
        },
        // 配置文件扩展名,导入时可以省略
        extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
    },

    // 配置CSS
    css: {
        // 配置预处理器选项
        preprocessorOptions: {
            // SCSS预处理器配置
            scss: {
                // 移除了additionalData,因为style.css已经在main.ts中导入
            },
        },
    },

    // 配置开发服务器
    server: {
        // 服务器端口
        port: 9000,
        // 自动打开浏览器
        open: true,
        // 允许外部访问
        host: '0.0.0.0',
        // 配置代理
        proxy: {
            // 将/test路径代理到http://localhost:3000
            '/test': {
                // 代理目标地址
                target: 'http://localhost:3000',
                // 改变请求头中的Origin
                changeOrigin: true,
                // 允许HTTPS证书无效
                secure: false,
            },
        },
    },

    // 配置依赖优化
    optimizeDeps: {
        // 预构建的依赖列表
        include: ['vue', 'vue-router', 'pinia', 'axios', 'element-plus', '@vueuse/core'],
    },
});

vite.dev.ts

// 导入Vite的defineConfig和mergeConfig函数
// defineConfig用于定义Vite配置,mergeConfig用于合并配置
import { defineConfig, mergeConfig } from 'vite';

// 导入公共配置
import baseConfig from './vite.base.ts';

// 导出开发环境配置
export default defineConfig(
    // 使用mergeConfig合并公共配置和开发环境配置
    mergeConfig(
        // 公共配置
        baseConfig,
        // 开发环境特定配置
        defineConfig({
            // 设置环境模式为development
            mode: 'development',
            // 配置全局常量替换
            define: {
                // Vue feature flag for hydration mismatch details
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
            },
            // 配置构建选项
            build: {
                // 构建输出目录
                outDir: 'dist',
                // 静态资源输出目录
                assetsDir: 'assets',
                // 开发环境启用sourcemap,便于调试
                sourcemap: true,
                // 开发环境不压缩代码,提高构建速度
                minify: false,
            },
        })
    )
);

vite.prod.ts

// 导入Vite的defineConfig和mergeConfig函数
// defineConfig用于定义Vite配置,mergeConfig用于合并配置
import { defineConfig, mergeConfig } from 'vite';

// 导入构建分析工具,用于生成构建包大小分析报告
import { visualizer } from 'rollup-plugin-visualizer';

// 导入Gzip压缩插件,用于压缩静态资源
import Compression from 'vite-plugin-compression';

// 导入公共配置
import baseConfig from './vite.base.ts';

// 导出生产环境配置
export default defineConfig(
    // 使用mergeConfig合并公共配置和生产环境配置
    mergeConfig(
        // 公共配置
        baseConfig,
        // 生产环境特定配置
        defineConfig({
            // 设置环境模式为production
            mode: 'production',
            // 配置全局常量替换
            define: {
                // Vue feature flag for hydration mismatch details
                __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: true,
            },
            // 配置生产环境特定插件
            plugins: [
                // 构建分析工具配置
                visualizer({
                    // 构建完成后不自动打开报告
                    open: false,
                    // 显示gzip压缩后的大小
                    gzipSize: true,
                    // 显示brotli压缩后的大小
                    brotliSize: true,
                    // 报告生成路径
                    filename: 'dist/stats.html',
                }),

                // Gzip压缩插件配置
                Compression({
                    // 输出压缩信息
                    verbose: true,
                    // 启用压缩
                    disable: false,
                    // 压缩阈值,大于10KB的文件才会被压缩
                    threshold: 10240,
                    // 压缩算法
                    algorithm: 'gzip',
                    // 压缩文件扩展名
                    ext: '.gz',
                }),
            ],

            // 配置构建选项
            build: {
                // 构建输出目录
                outDir: 'dist',
                // 静态资源输出目录
                assetsDir: 'assets',
                // 生产环境禁用sourcemap,减少文件大小
                sourcemap: false,
                // 生产环境使用terser进行代码压缩
                minify: 'terser',

                // terser压缩配置
                terserOptions: {
                    // 压缩选项
                    compress: {
                        // 移除console语句
                        drop_console: true,
                        // 移除debugger语句
                        drop_debugger: true,
                    },
                },

                // 输出配置(使用rolldown替代rollup)
                rollupOptions: {
                    output: {
                        // 入口文件命名规则
                        entryFileNames: 'assets/js/[name].[hash].js',
                        // 代码块命名规则
                        chunkFileNames: 'assets/js/[name].[hash].js',
                        // 静态资源命名规则
                        assetFileNames: 'assets/[ext]/[name].[hash].[ext]',

                        // 手动代码分割配置
                        manualChunks: {
                            // 将Vue相关库打包到vue-vendor chunk
                            'vue-vendor': ['vue', 'vue-router', 'pinia'],
                            // 将Element Plus打包到element-plus chunk
                            'element-plus': ['element-plus'],
                            // 将Axios打包到axios chunk
                            axios: ['axios'],
                            // 将@vueuse/core打包到@vueuse/core chunk
                            '@vueuse/core': ['@vueuse/core'],
                        },
                    },
                },
            },
        })
    )
);

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

1. 开发历程

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

2026-01-10-14-22-50.png

2026-01-10-14-23-15.png

2026-01-10-14-23-23.png

2.用户积累

到目前2026年1月10日为止,已经积累了872位用户,感谢你们的使用和支持!

2026-01-10-14-23-32.png

2026-01-10-14-23-42.png

我在小程序上线后也没怎么宣传,只写了一篇公众号有1200多浏览量,然后全靠自然流量。虽然用户增长数据可观,但是留存数据不容乐观,从之前的日活:70多人到现在稳定在10几人。这也让我怀疑是不是我做的东西不好用,帮助不了用户,为了 防止闭门造车 我会积极听取身边朋友和用户的反馈和建议,作出调整优化, 让它越来越好 。 无论未来如何,这个项目我会一直更新迭代下去, 服务更多的用户,它是我的第一个完整的作品,同时也是我迈出独立开发的第一步!我一定会认真对待。

3. 收益

目前收益途径仅有流量主,功能全部是免费的。为了不影响用户的体验在流量主开通后,我只引入了开屏广告。目前流量主开通了3个月,一共收入69.32。平均每天是0.4左右,收益等于没有,哈哈。没有收益的软件如同无源之水,无根之木,是不长久的,也容易让用户产生我会随时跑路的担忧,我的计划是等我把福猪记账做得更完整更好的时候,再推出VIP功能。

2026-01-10-14-24-02.png

4.感悟

开发福猪记账是我迈向独立开发的第一步,让我意识到做demo和做产品有着云泥之别。从能看的demo,做到能用,再从能用做到好用,是需要动脑子,下功夫的。需要考虑很多东西比如UI设计、用户体验、用户引导、功能测试等等。眼高手低是我常犯的一个错误,也是我要尽快克服的毛病,避免完美主义,完美的前提是完成。

做独立开发,要缩小程序员思维占的位置,要将产品思维、运营思维加入进来。在公司上班,你只需要关注你的一亩三分地即可,功能实现了没问题你就可以点杯咖啡开始划水了。但是独立开发不行,你要既当爹又当妈,不要太过于注重代码写的是否美观、健壮,也不要上来就用这个框架、那个技术、设计模式、算法咔咔一通乱整。前期一定要避免这样,不然精力很快就会被耗尽,你需要考虑的是什么可以让你的功能快速实现并上线!别管白猫黑猫逮到耗子就是好猫。

AI真的改变了我们的开发模式,它帮我节省了大量开发的时间,我们应该积极拥抱这种变化。我敢说如果没有AI的协助,我这个项目至少要双倍的时间来完成,甚至完不成!但是也不像网上说的那样离谱,一句话AI就可以给你做出一款APP,那不现实,即使做出来也存在很多问题,需要大量时间调整修复。通过这段时间和AI协作开发,我发现AI的能力的天花板其是你个人能力的天花板,你强AI就强,你就像是一个指挥官,AI帮你干活,但是如果你能力不行专业知识不够,瞎指挥,AI也变得傻乎乎的,它做不出来你脑子里面没有的东西!在AI迅速发展的时代,我应该着重培养自己的思维能力:代码思维、产品思维。我记得一句话是这样说的:代码是在你脑海中形成的,只要思路正确,写代码只是一个堆砌的过程。现在AI帮我做了写代码这一步,那么我们就应该学会如何将脑海中的思维清晰的表述出来,让AI听得懂,并且按照我们的思路去做。

最重要的永远在最后:产品定位和运营宣传,你东西做的再好没人知道也是不行的。产品定位很重要,如果你选择的是红海领域比如记账,这个类型的应用随便一搜索就不下10几20个,要从头部app中抢流量这太难了,但是我们再仔细想一个问题:为什么记账app已经那么多了,偶尔还是会出现一两款记账软件呢?我的答案是:没有一款软件可以满足所有用户的需求!比如我就是其中之一,我相信肯定也有其他人和我一样,所以我要做到差异化,如果只是想复刻别人产品那是行不通的,差异化可以是功能、UI、交互。避免大而全,专注小而美,聚焦某一类特定的用户需求,才是破局之道。至于宣传,没有钱来投广告那就靠自己,除了在各大平台发文章宣传方式外别无他法,这种冷启动实是无奈之举,这也更加体现了打造个人IP的重要性。

独立开发是一条夜路,没有目标、没有终点,一切都是未知数,但是我喜欢未知数,这表示我的生活有着无限的可能!

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

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

一、原型和原型链的核心难点拆解

难点 1:__proto__prototypeconstructor 三者的关系(最易混淆)

这三个属性是原型体系的 “三角关系”,新手很容易记混、用错,先看核心结论

  • prototype:函数独有,指向“原型对象”,原型对象里默认有constructor属性。
  • __proto__所有对象独有,指向自己的“原型对象”。
  • constructor:原型对象独有,指向创建该原型对象的构造函数。

核心公式(必记)

实例.__proto__ === 构造函数.prototype
构造函数.prototype.constructor === 构造函数
实例.constructor === 构造函数 (本质是通过原型链查找 constructor)
function Person(name) {
this.name = name
}
const res = new Person('老王')
// 验证1,实例的隐式原型(__proto__)=== 构造函数的显式原型(prototype)
console.log(res.__proto__ === Person.prototype,) // true
// 验证2,原型对象的constructor指向构造函数
 console.log(Person.prototype.constructor === Person) // true
// 验证3,实例的本身没有constructor 是通过原型链找到原型对象的constructor
 console.log(res.constructor === Person) //true

原型对象本身也是对象,因此它的 __proto__ 会指向更高层的原型对象,最终形成原型链

// Person.prototype 是对象,其 __proto__ 指向 Object.prototype
Person.prototype.__proto__ === Object.prototype; // true

// Object.prototype 是原型链的顶端,其 __proto__ 为 null
Object.prototype.__proto__ === null; // true

注意点:修改 prototype 会影响 constructor

若直接覆盖构造函数的 prototype(而非添加属性),会丢失原有的 constructor(默认指向 Object),需手动恢复

// 错误示例:直接覆盖prototype,constructor会指向Object
Person.prototype = {
  sayHi: function() { console.log('Hi'); }
};
p.constructor === Object; // true(不符合预期)

// 正确做法:覆盖后手动设置constructor
Person.prototype = {
  constructor: Person, // 手动指向原构造函数
  sayHi: function() { console.log('Hi'); }
};
p.constructor === Person; // true(恢复预期)

总结三者的核心关系

构造函数(如Person)
  ↓ 拥有
  prototype → 原型对象(如Person.prototype)
                ↓ 拥有
                constructor → 构造函数(Person)
实例(如p)
  ↓ 拥有
  __proto__ → 原型对象(Person.prototype

二,原型和原型链的核心理解

简单来说每个函数创建都会默认添加一个prototype的属性,创建每个对象也会有一个__proto__的属性,上面也验证过 实例的__proto__(隐式原型)是指向构造函数的prototype(显示原型的)两者是相等的。

构造函数.prototype也是一个对象,所以也会有一个__proto__,指向的是Object.prototype,因此Object.prototype也是一个对象,所以也会有__proto__,Object.prototype.proto 指向的却是null,因为这是原型链的最顶层了,顶层就是null

原型链:实例会先从自身上找,没找到会通过obj.proto 从原型对象上去找(构造函数.prototype),没找到会通过obj.proto.proto 从原型对象上的原型上去找(构造函数.prototype.proto),直到最顶层Object.prototype去找没找到,就回去Object.prototype.proto 值为null 结束 返回 undefined

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

        const obj = new Person('zhangsan')
        Person.prototype.age2 = 30
        Object.prototype.age3 = 40
        console.log(obj.age) //访问的是自身上的属性
        console.log(obj.age2) //访问的是原型对象上的属性
        console.log(obj.age3) //访问的是原型对象的原型上的属性

        /**
         * obj={
         *   console.log(obj.age) 30  先找自身
         * __proto__ === 构造函数.prototype {
         *     console.log(obj.age2) 30  如果自身没有找到 就会去找原型对象
         *   Person.prototype.__proto__ === Object.prototype ={
         *       console.log(obj.age3) 40  原型对象上没有  就会去找原型对象上的原型上去找
         *     }
         *      Object.prototype.__proto__ {
         *        直到最顶层  Object.prototype 它的__proto__ === null  所有原型链的最顶层就是
         *      }  
         *   
         *   }
         * 
         * }
         * **/

推荐几个国外比较流行的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

一、安装

npm install antd --save
  • yarn
yarn add antd
  • pnpm
pnpm install antd --save

二、main引用

  • 引入Ant Design的reset.css文件
  • 引入ConfigProvider全局配置,且包含所有组件
  • 设置locale属性为中文
// 导入严格模式组件,用于开发环境下检测潜在问题
import { StrictMode } from 'react';
// 导入客户端渲染方法,用于创建根节点并渲染应用
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
// 导入Ant Design的样式文件
import 'antd/dist/reset.css';
// 导入全局自定义样式文件
import './index.css';
// 导入Ant Design配置提供者组件
import { ConfigProvider } from 'antd';
// 导入Ant Design的中文语言包
import zhCN from 'antd/locale/zh_CN';

// 获取 HTML 中 id 为 'root' 的 DOM 元素作为应用根节点
const rootElement = document.getElementById('root');

// 如果找不到根节点,则抛出错误
if (!rootElement) {
throw new Error('找不到id为"root"的DOM元素,请检查index.html文件');
}

// 创建 React 根节点
const root = createRoot(rootElement);

// 渲染整个 React 应用
root.render(
// 使用严格模式包装应用
// 注意:严格模式只在开发环境生效,生产环境会自动禁用
<StrictMode>
{/*
 ConfigProvider 是 Ant Design 的全局配置组件
 此处设置 locale 属性为中文语言包,实现组件国际化
 可以在这里添加更多全局配置,如主题定制等
 */}
<ConfigProvider locale={zhCN}>
{/*
 应用主组件
 所有页面和路由都将在 App 组件内部定义和管理
 */}
<App />
</ConfigProvider>
</StrictMode>
);

三、组件引用

  • 引用Button,查看使用效果
import { Button } from 'antd';

function App() {
return (
<div>
<Button type="primary">按钮</Button>
</div>
);
}

export default App;

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 定位;
  • ② 路径定位时支持 “模糊匹配”(如忽略动态索引,通过类名 + 标签名组合定位);
  • ③ 定位失败时触发 “降级处理”(如跳过该事件,避免整个回放崩溃)。

jsx/tsx使用cssModule和typescript-plugin-css-modules

1,前言

vite/webpack搭建的项目中,不管是vue还是react,都可以写jsx/tsx,为了避免样式污染,常用的方式有两种。一种是每个组件都用一个唯一类名class包裹,使用less/scss嵌套样式。另一种是使用cssModule模块化。本文就分享一下如何使用cssModule,并推荐一个好用的插件:typescript-plugin-css-modules,让你在vscode中,能拥有typeScript一样的智能提示。

2,效果图

效果图

类型提示

类型提示

3,如何使用

注:本文各种配置均使用vscode编辑器。

3.1,安装

  • yarn
yarn add -D typescript-plugin-css-modules
  • npm
npm install -D typescript-plugin-css-modules

3.2,配置

配置后需要重启vscode,然后项目中使用cssMoudule时,就可以享受到typeScript提示的class类名了,配置如下:

  • 配置tsconfig.json
{
  "compilerOptions": {
    "plugins": [{"name": "typescript-plugin-css-modules"}]
  }
}
  • 配置settings.json

在项目根目录新建.vscode文件夹,在文件夹中新建settings.json,并写入如下配置,用于指明使用typescript.tsdk的位置以及开启提示,如果vscode有提示,记得同意。

{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}

注意:cssModule可以用于css,less,scss等,使用时,css/less/scss文件后缀必须由.css/.less/.scss变为 .module.css/.module.less/.module.scss

4,示例

  • index.tsx
import { defineComponent } from 'vue'
import styles from './index.module.scss'

export default defineComponent({
  name: 'notFound',
  setup() {
    return () => (
      <div class={styles.main_box}>11111</div >
    )
  }
})
  • index.module.scss
.main_box {
  width: 100%;
  height: 100%;
  position: relative;
  overflow: hidden;
  text-align: center;
  background-color: #ffffff;
}

5,插件错误处理

截止本文发布之时,typescript-plugin-css-modules的版本为3.4.0,此插件有一个bug,会导致cssModule类型提取失败,模块类型是一个{}的情况,如下所示:

Property '' does not exist on type {}

类型提取错误

issues地址

5.1,错误触发原因

这个bug目前有两个方式都会触发:

  • 1,当你项目中使用less/scss@include/@mixin等等指令的时候

  • 2,当你的项目使用/ deep /这样的深度选择器语法的时候

5.2,解决办法

  • 1,在需要使用@include/@mixin等等指令的时候,在cssModule文件的头上引入样式,就可以解决(之前是全局引入),如下所示:
@use "../../../static/styles/common.scss" as *;
  • 2,换一种深度选择器写法,如下所示:
.main{
  & ::deep .el-button{
    background-image: linear-gradient(-90deg, #29bdd9 0%, #276ace 100%);
    &:hover{
      opacity: 0.8;
    }
  }
}

本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

前端向架构突围系列 - 框架设计(二):糟糕的代码有哪些特点?

前言 你有没有过这种经历:新接手了一个项目,产品经理让你把一个按钮往左移 5 像素。你心想:“这不有手就行?” 结果你改了 CSS,保存,刷新。 按钮是移过去了,但登录弹窗打不开了,控制台红了一片,甚至 CI/CD 流程都挂了。

这一刻,你面对的不是代码,而是一座摇摇欲坠的屎山。 在框架设计和组件库开发中,这种现象尤为致命。业务代码写烂了,坑的是一个页面;框架设计写烂了,坑的是整个团队。今天我们要聊的不是具体的变量命名或缩进,而是架构层面的“设计臭味”

image.png

什么是“设计臭味”?

“代码臭味”(Code Smell)这个词不是说代码真的有味儿(虽然有时候看代码确实想吐),而是指代码结构中某些特征暗示了深层次的设计问题

它就像煤气泄漏的味道,本身不一定会炸,但只要有一点火星(新的需求变更),整个系统就会原地升天。

作为前端架构师或核心开发者,如果你在 Code Review 时闻到了以下这 5 种味道,请务必警惕。


1. 僵化性 (Rigidity):牵一发而动全身

症状: 你想复用一个通用的 Header 组件,结果发现它里面硬编码了 useRouter() 的跳转逻辑,甚至还直接 importRedux/Pinia 的 store。 你想在另一个项目用它?没门。除非你把那边的路由和状态管理全套搬过来。

前端实战翻译: 这就是典型的高耦合。组件不再是一个独立的乐高积木,而是一块焊死在主板上的芯片。

反面教材 (React):

// 这是一个充满僵化味道的组件
const UserProfile = () => {
  // 致命伤1:直接依赖具体的全局状态
  const user = useSelector(state => state.user.info);
  // 致命伤2:直接依赖具体的路由实现
  const history = useHistory();

  const handleLogout = () => {
     // 业务逻辑耦合在UI里
     api.logout().then(() => history.push('/login'));
  }

  return <div>{user.name} <button onClick={handleLogout}>退出</button></div>;
};

指南:

  • 控制反转 (IoC) :组件只管展示,逻辑通过 Props 传进来。
  • Presentational vs Container:把“展示组件”和“容器组件”拆开。展示组件要像“傻瓜”一样,给什么吃什么,不要自己去冰箱(Store)里拿。

2. 脆弱性 (Fragility):改东崩西的蝴蝶效应

症状: 这比僵化性更搞心态。僵化性是你改不动,脆弱性是你改了,但崩在了你完全想不到的地方。 比如:你为了优化首页加载速度,调整了一个公共 utils 函数,结果结算页面的金额计算错了,多给了用户 100 块钱。

前端实战翻译: 通常源于隐式依赖全局变量污染或者CSS 样式穿透

反面教材 (CSS/Vue):

/* 这种写法在全局样式里简直是灾难 */
.title {
  font-size: 20px;
  color: red;
}

/* 或者在组件里滥用 !important */
.btn {
  background: blue !important; /* 你的同事想覆盖这个样式时,必须写得比你更恶心 */
}

指南:

  • CSS Modules / Scoped CSS / Tailwind:坚决消灭全局样式冲突。
  • 纯函数 (Pure Functions) :工具类函数坚决不能有副作用,输入相同,输出必须相同。
  • 依赖显式化:别在组件里偷偷摸摸读 window.xxx 或者 localStorage,把它们封装成 Hooks 或服务。

3. 顽固性 (Immobility):无法拆分的连体婴

症状: 你写了一个非常炫酷的 DataGrid 表格,支持排序、筛选、分页。隔壁组看到了说:“哇,这个好,我也要用。” 你自信满满地把代码发给他。 五分钟后他跑来说:“哥,我只要个表格UI,你为什么把 Axios 拦截器、ElementUI 的弹窗组件、甚至你们公司的埋点 SDK 都打包进来了?”

前端实战翻译: 这是内聚性低的表现。业务逻辑和基础设施、UI 逻辑混在一起,导致根本无法拆分复用。

反面教材:

JavaScript

// 一个原本想做通用组件的 hook,却混入了业务
function useTableData(url) {
  const [data, setData] = useState([]);

  useEffect(() => {
    // 错误:这里耦合了特定的 HTTP 库和业务上的 token 逻辑
    axios.get(url, { headers: { 'X-Auth': localStorage.getItem('token') } })
      .then(res => setData(res.data.list)); // 错误:硬编码了数据结构 res.data.list
  }, [url]);

  return data;
}

指南:

  • Headless UI:这是现在的设计趋势(如 React Table, TanStack Query)。只提供逻辑钩子,不提供 UI。
  • 依赖倒置:网络请求层应该作为参数传入,而不是在组件内部直接实例化。

4. 粘滞性 (Viscosity):做错误的事更容易

症状: 这是一个人性问题。 假设你的框架支持 TypeScript。

  • 正确的做法:定义 Interface,继承 Props,处理泛型,写 Mock 数据,跑单元测试。需要 10 分钟。
  • 错误的做法any 一把梭。需要 10 秒钟。

做正确的事做错误的事阻力大得多时,开发者就会倾向于破坏架构。这就是粘滞性。

前端实战翻译: 环境配置太复杂、类型定义太反人类、测试难写。

反面教材: 如果你的组件库要求使用者必须写 5 层嵌套的配置对象才能跑起来,那使用者一定会想办法绕过配置,直接去改源码。

指南:

  • 约定优于配置:像 Next.js 或 Nuxt.js 那样,文件放对位置路由就自动生成了。
  • 提供开箱即用的类型:别让用户自己去猜泛型填什么。
  • 路径依赖设计:让最简单的写法,就是最佳实践。

5. 晦涩性 (Opacity) & 过度设计 (Needless Complexity)

症状: 这两个往往相伴而生。 你打开一个同事的代码,看到了一堆 AbstractFactoryProviderHighOrderComponentWrapper。 你只是想渲染一个输入框,结果你需要先创建一个 FormConfig,再实例化一个 FieldBuilder,最后通过 RenderProp 传进去。

开发者看着你的代码会感叹:“虽然看不懂,但感觉很厉害的样子。” —— 别傻了,他们心里在骂娘。

前端实战翻译: 为了封装而封装。比如把简单的 if-else 逻辑抽象成极其复杂的策略模式,或者写了无比抽象的 Hooks,结果参数传了 8 个,返回值 12 个。

指南:

  • YAGNI 原则 (You Ain't Gonna Need It):不要为你臆想的未来需求写代码。
  • 代码如文章:好的代码应该像大白话一样。如果一段代码需要你写 10 行注释来解释“我为什么要绕这么大弯子”,那通常意味着设计失败。
  • 组合优于继承,简单优于抽象:在前端,特别是 React Hooks 中,平铺直叙的逻辑往往比层层嵌套的高阶组件要好维护得多。

总结:如何避免成为“制造臭味”的人?

设计框架就像盖楼。

  • 僵化性是钢筋没绑好,想改户型得拆承重墙。
  • 脆弱性是地基没打牢,楼上装修楼下漏水。
  • 顽固性是水电管线混在一起,修电线得砸水管。
  • 粘滞性是垃圾道设计不合理,大家只好往楼下扔垃圾。

要去除这些“味道”,最核心的心法只有一句话:

保持代码的“软”度 (Software)。

软件之所以叫软件,是因为它应该是易于改变的。当我们写下一行代码时,多问自己一句: “如果明天这个需求变了,我今天写的这行代码是资产,还是债务?”


互动话题

你的项目里有没有那种“甚至不敢看它一眼,怕看一眼它就崩了”的代码?或者你见过最离谱的“过度设计”是什么样的?欢迎在评论区晒出你的“受苦”经历,让大家开心一下(划掉)避避坑。

Blog-SSR 系统操作手册(v1.0.0)

Blog-SSR 系统操作手册(v1.0.0) 📖 文档概述 本文档是 Blog-SSR 系统的完整操作手册,旨在帮助开发者从零开始搭建和运行整个博客系统。系统采用现代化的全栈技术栈,包含前后端分离架

Docker 浅谈

Docker 是什么 你可以把 Docker 理解为软件世界的集装箱。就如 Docker logo 所展示的,是一头鲸鱼驮着若干集装箱。Docker 可以允许开发者将应用以及所有依赖项打包,包括环境。
❌