前端轮询那些事儿

当AI开始"说人话"------从大模型流式回答说起

深夜,你盯着屏幕上ChatGPT的回答逐字跳出,仿佛有个隐形的打字员在网络另一端敲击键盘,站在用户的角度,这种"流式回答"的魔法,好像让冰冷的技术有了一丝温度。那么站在开发者的视角,这种流式输出(即数据实时更新)的背后,是用的什么来实现的呢?

这就是我们今天要讨论的内容,前端轮询的那些事儿------如何优雅实现实时数据更新。

一、 页面刷新:原始人的刀耕火种

在前端最原始的时代,网页的内容还是极其简单的门户网站、博客等,这个时候甚至Ajax技术都还没诞生,浏览器用户为了获取最新的网站内容,往往都是通过最原始的方式------刷新页面。通过浏览器刷新页面,重新加载整个页面内容,包括HTML/JS/CSS在内的所有资源都会被重新加载。

典型的例子包括新闻网站的最新内容更新以及博客留言板上的评论更新。在这种情况下,数据更新与"实时"的概念毫无关联,完全依赖于用户刷新页面时按F5键的速度。

虽然说这个时候也有一些非刷新页面的数据更新方式(比如通过隐藏的iframe等),但是这些都属于hack手段,不是标准化的技术,可以认为在Ajax普及之前,页面刷新是主要的数据更新方式。

二、 短轮询:最直白的请求

这种情况的改善是在2005年左右,随着Ajax(Asynchronous JavaScript and XML)的提出和兴起,这种通过异步发送HTTP请求,并使用这些服务端返回的内容更新页面相关部分,而无需重新加载整个页面的技术,让人们看到了数据实时更新的希望。

当然这里指的是浏览器端,Ajax是浏览器端的一种请求技术,对于其他客户端比如桌面端,本身就支持异步请求。

技术原理

短轮询的技术原理很简单,就是前端设置一个定时器,每隔一定的时间(比如3s)就发送一次普通的HTTP请求到服务端,查询有没有最新的数据,服务端在收到请求后返回最新的数据结果或者通知前端无更新内容。

浏览器端代码示例

javascript 复制代码
// 短轮询:定时发送请求
function shortPoll() {
  setInterval(async () => {
    const response = await fetch('/short-poll-api');
    const data = await response.json();
    console.log('Short Polling:', data);
  }, 3000); // 每3秒请求一次
}

服务端代码示例

javascript 复制代码
const express = require('express');
const app = express();

// 短轮询处理:直接返回当前数据
app.get('/short-poll-api', (req, res) => {
  const hasNewData = checkDataUpdate(); // 模拟检查数据更新
  res.json({ data: hasNewData ? 'New Data' : 'No Data' });
});

协议视角

从代码实现上来看,短轮询很简单,就是一次普通的接口请求,似乎没什么不妥。而从网络协议角度看就会发现短轮询的问题。

我们都知道HTTP是无状态的,HTTP基于的TCP是有状态的。每次发起短轮询的HTTP请求都会先建立TCP连接,而每个TCP连接在建立的时候都会经过三次握手,在短轮询结束之后会断开连接,又会经过四次挥手。

即每次请求都是一个独立的TCP连接,每次TCP连接的建立和断开,都是比较大的网络资源消耗,而且大多数情况下是没有数据更新的,无效请求数量较高。

即便是在Ajax技术诞生时HTTP/1.1中已经默认启用了Connection: Keep-Alive来开启TCP长连接,使多个HTTP请求可以复用一个TCP连接,并且可以通过pipelining技术来同时发生多条请求,但是在HTTP/1.1的时代,还是存在队头阻塞单域名连接数量的限制(单个域名限制连接数量6个)的问题,同时大量的用户的短轮询也会给服务端带来比较大的压力。

更多HTTP/1.x连接管理的内容:HTTP/1.x 的连接管理 - HTTP | MDN

优缺点和应用场景

  • 优点:
    • 十分简单:浏览器和服务端都不需要做任何特殊逻辑,直接请求即可
    • 兼容性极佳(所有浏览器都支持)
  • 缺点:
    • 频繁TCP连接建立/关闭(未启用长连接时)
    • 无效请求占比高(无数据更新时),浪费网络带宽和服务器资源
    • 数据更新的实时性较差,实时性取决于轮询的间隔
    • 访问量较大的大型网站,对服务端会有较大的压力
  • 应用场景:
    • 对实时性要求没那么高的网站和小型网站
    • 简单的通知和定时查询的场景

三、 长轮询:等待的艺术

既然短轮询发送太频繁,服务端即便在没有数据更新的时候也会返回数据,那么有没有什么优化方式呢?有的,长轮询。

技术原理

针对短轮询的问题,我们自然而然地可以想到一个对短轮询的优化方式:在服务端收到请求后不立刻返回,而是把请求挂起,一直等待直到有数据更新或者超时。浏览器端在收到数据响应或者超时后,再重新发起一次请求。

浏览器端代码示例

javascript 复制代码
// 长轮询:递归调用,响应后立即重新请求
function longPoll() {
  fetch('/long-poll-api')
    .then(response => response.json())
    .then(data => {
      console.log('Long Polling:', data);
      longPoll(); // 立即发起下一次请求
    })
    .catch(err => {
      console.error('Error:', err);
      setTimeout(longPoll, 1000); // 错误时延迟重试
    });
}

服务端代码示例

javascript 复制代码
// 长轮询处理:挂起请求直到数据更新或超时(使用 EventEmitter 模拟数据更新)
const express = require('express');
const EventEmitter = require('events');

const app = express();
const dataEmitter = new EventEmitter();

app.get('/long-poll-api', (req, res) => {
  const timeout = setTimeout(() => {
    res.json({ data: null }); // 超时返回空数据
  }, 30000); // 强制30秒超时

  // 监听数据更新事件
  const dataHandler = (data) => {
    clearTimeout(timeout);
    res.json({ data });
  };

  dataEmitter.once('update', dataHandler);

  // 客户端断开连接时清理资源
  req.on('close', () => {
    clearTimeout(timeout);
    dataEmitter.off('update', dataHandler);
  });
});

// 模拟数据更新
setInterval(() => {
  dataEmitter.emit('update', 'New Data');
}, Math.random() * 15000);

协议视角

从协议视角来看连接保持的原理,就是:当服务端不立即返回响应时,TCP连接会保持打开状态(由 HTTP Keep-Alive 机制管理),直到:

  • 服务端主动发送响应
  • 客户端主动取消请求
  • 中间设备(代理、负载均衡器)触发超时
  • 操作系统强制关闭连接

正是因为HTTP/1.1支持了长连接,才有了长轮询对短轮询的改进。

这里插入一句:

根据RFC 7230第6.3节,在HTTP/1.1中默认启用了Connection: Keep-Alive,因此在HTTP/1.1的响应头不需要显式设置这个响应头。 另外根据RFC 7540第8.1.2.2节,HTTP/2 明确禁止使用Connection字段,现代浏览器(如 Safari)在HTTP/2下会严格遵循规范,可能导致连接被拒绝。

所以请只有在明确需要兼容HTTP/1.0时再设置Connection: Keep-Alive

优缺点和应用场景

  • 优点:
    • 请求频率低:对比短轮询大大降低了请求频率
    • 实时性对比短轮询要好
    • 兼容性好
  • 缺点:
    • 资源占用:服务器需要保持与每个客户端的连接,高并发时压力大
    • 超时重连时可能会有一些实时性的延迟
    • 实现较短轮询来说复杂一些
  • 应用场景:
    • 中等实时性需求,如邮件通知、订单状态更新等
    • 作为无法使用 WebSocket/SSE 时的替代方案

四、 SSE:来自服务端的推送

从上面的短轮询和长轮询的应用场景我们发现,这些场景大多是服务端到前端的单向的数据更新,如果是这样的话,SSE(Server-Sent Events)会是一个更好的选择。

在2008年左右,SSE作为HTML5标准提案的一部分被提出,支持服务端单向推送,之后在2011年,SSE通过EventSource API实现了标准化,并被主流浏览器逐步支持,最终在HTTP/2中成为正式标准。

而大多数的网页端的AI大模型对话的流式回答,也正是通过SSE来实现的。

技术原理

SSE和长轮询一样,都是服务端收到请求后不立刻返回,而是保持这个连接打开,之后服务器按照如下要求进行返回:

  • 响应头:Content-Type: text/event-stream,用于标识SSE数据流
  • 数据格式:每条消息由data:开头,以\n\n结尾(即用双换行符分隔)

服务端代码示例

javascript 复制代码
const express = require('express');
const path = require('path');

const app = express();
const PORT = 3000;
app.use(express.static(path.join(__dirname)));

app.get('/sse', (req, res) => {
  res.writeHead(200, {
    // 设置SSE需要的响应头
    'Content-Type': 'text/event-stream',
    // 为了保证前端展示的是最新数据,需要设置 Cache-Control: no-cache 
    'Cache-Control': 'no-cache',
  });

  // 每隔一段时间发送一次数据,模拟实时数据流
  const interval = setInterval(() => {
    const data = {
      time: Date.now(),
      // 生成随机字符串模拟数据
      msg: Math.random().toString(36).substring(2, 12),
    };

    // SSE数据格式要求
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  }, 500);

  // 断开连接时清理资源
  req.on('close', () => {
    clearInterval(interval);
    res.end();
    console.log('Client disconnected');
  });

  console.log('Client connected');
});

// 启动服务器
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

在浏览器端,则通过浏览器通过EventSource API监听服务器推送:

javascript 复制代码
let source;
const start = () => {
  console.log('Started listening to events');
  source = new EventSource('/sse');
  source.onmessage = (event) => {
    const data = JSON.parse(event.data);
    document.getElementById('messages').innerHTML += `<p>接收到数据: ${data.msg}</p>`;
  };
}

const stop = () => {
  if (source) {
    console.log('Stopped listening to events');
    source.close();
    document.getElementById('messages').innerHTML += '<p>Connection closed</p>';
  }
}

效果:

协议视角

从协议视角来看,SSE仍然是基于HTTP协议的一个普通请求,并且和长轮询一样是收到请求后不立刻结束请求,而是保持连接打开,通过设置特定的响应头,在同一个请求里返回多个特定格式的数据片段。

和长轮询不同的是,长轮询是有数据更新时会立刻返回并结束当前请求,需要前端重新发起一个请求。而SSE则是可以持续不断地返回数据,直到数据发送完毕服务端断连接或者前端主动断开连接。

如果因为网络错误等导致连接断开,浏览器默认自动尝试重新连接。

而这一切只需要按照SSE的规范,设置响应头Content-Type: text/event-stream和按照特定的数据格式返回即可。

原生EventSource的不足

通过EventSource虽然可以实现SSE,但是有一些限制:

  • EventSource只能支持GET请求

    在AI大模型对话的这种场景来看,GET请求明显不合适,GET请求只能在URL的query中添加数据,但是浏览器对于URL的长度是有限制的,而且一些敏感的数据也不适合放到query里,这种情况下使用POST请求是更好选择

  • EventSource只支持发送UTF-8编码的文本,不支持发送二进制流

针对这两个问题,可以尝试不使用EventSource,而是用fetch来实现SSE:

  • 前端请求的时候,使用fetch API和ReadableStream可读流来代替EventSource
  • 服务端支持POST方法的请求
  • 前端和服务端自己实现event-stream的组装和解析

这里我们用一个模拟AI大模型回答的例子来看下POST请求的SSE如何实现。

模拟实现AI大模型回答

服务端代码示例

javascript 复制代码
const express = require('express');
const path = require('path');
const fs = require('fs');

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

// 中间件
app.use(express.static(path.join(__dirname)));
app.use(express.json());

// POST SSE
app.post('/post-sse', (req, res) => {
  console.log('收到POST请求:', req.body);

  // 获取前端提交的数据
  const clientName = req.body.name;
  const question = req.body.question;
  
  console.log(`收到来自 ${clientName} 的POST SSE请求,用户的问题是: ${question}`);
  
  // 设置SSE所需的响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
  });

  // 发送初始消息,包含客户端提供的数据,并在末尾添加换行
  res.write(`data: {"message": "你好,${clientName}!收到了你的问题:${question}\\n\\n"}\n\n`);

  let intervalId;
  // 读取 ./text.md 文件 模拟大模型回答问题
  const textFilePath = path.join(__dirname, 'text.md');
  fs.readFile(textFilePath, 'utf8', (err, data) => {
    if (err) {
      console.error('读取文件时出错:', err);
      res.write('data: {"message": "读取文件时出错"}\n\n');
      return;
    }
    
    // 每隔 50~100ms 发送 3~5 个字符,模拟大模型回答问题
    let index = 0;
    intervalId = setInterval(() => {
      if (index >= data.length) {
        clearInterval(intervalId);
        // 发送完成标记
        res.write('data: {"message": "", "answerFinished": true}\n\n');
        console.log(`已发送完 ${clientName} 的问题的回答`);

        // 延迟一点时间再结束响应,确保最后的消息能被客户端接收
        setTimeout(() => {
          console.log(`结束 ${clientName} 的 SSE 连接`);
          res.end();
        }, 500);
        return;
      }
      
      const chunkSize = Math.floor(Math.random() * 3) + 3;
      const chunk = data.slice(index, index + chunkSize);
      index += chunkSize;
      
      // 确保特殊字符被正确转义,特别是换行符
      const escapedChunk = JSON.stringify(chunk).slice(1, -1);
      
      res.write(`data: {"message": "${escapedChunk}"}\n\n`);
      console.log(`已发送 ${chunk.length} 个字符给 ${clientName}`);
    }, getRandomInterval(50, 100));
  });

  // 断开连接时清理资源
  res.on('close', () => {
    console.log(`客户端 ${clientName} 断开连接`);
    intervalId && clearInterval(intervalId);
    res.end();
  });
});

// 生成随机时间间隔
function getRandomInterval(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

// 启动服务器
app.listen(PORT, () => {
  console.log(`SSE 服务器运行在 http://localhost:${PORT}`);
});

前端代码示例

javascript 复制代码
let abortController = null;
const responseElement = document.getElementById('aiResponse');

async function sendQuestion() {
  let accumulatedResponse = '';
  try {
    // 创建AbortController用于取消请求
    abortController = new AbortController();
    const signal = abortController.signal;
    
    // 准备请求数据
    const requestData = {
      name: '张三',
      question: 'xxx'
    };
    
    // 发送POST请求
    const response = await fetch('/post-sse', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream'
      },
      body: JSON.stringify(requestData),
      signal: signal
    });
    
    if (!response.ok) {
      throw new Error(`服务器返回错误: ${response.status}`);
    }
    
    // 处理SSE数据流
    console.log('已连接,正在接收数据...');
    
    // 获取可读流并创建读取器
    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    
    // 读取数据流
    while (true) {
      const { done, value } = await reader.read();
      
      if (done) {
        console.log('数据接收完成');
        break;
      }
      
      // 解码二进制数据
      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;
      
      // 处理SSE格式数据(以\n\n分隔)
      const events = buffer.split('\n\n');
      buffer = events.pop() || ''; // 保留可能不完整的最后一部分数据
      
      // 处理每个事件
      for (const event of events) {
        if (event.trim()) {
          // 解析SSE数据行
          const dataMatch = event.match(/^data: (.+)$/m);
          if (dataMatch) {
            try {
              const data = JSON.parse(dataMatch[1]);
              if (data.message !== undefined) {
                // 追加消息到 accumulatedResponse
                accumulatedResponse += data.message;
                
                // 将累积的响应数据 accumulatedResponse 更新到页面
                responseElement.innerHTML = accumulatedResponse;
              }
              
              // 如果收到完成标志,中断连接
              if (data.answerFinished === true) {
                stopRequest();
                console.log('回答完成');
              }
            } catch (e) {
              console.error('解析JSON出错:', e);
              // 如果不是JSON,直接显示文本
              accumulatedResponse += dataMatch[1];
              responseElement.innerHTML = accumulatedResponse;
            }
          }
        }
      }
    }
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('请求已取消', false);
    } else {
      console.error('请求出错:', error);
      console.log(`错误: ${error.message}`, false);
    }
  } finally {
    abortController = null;
  }
}

// 停止请求
function stopRequest() {
  if (abortController) {
    abortController.abort();
    console.log('正在停止请求...', false);
  }
}

在服务端,我们通过app.post支持了post请求,并且通过读取一个markdown文件并随机返回不同数量的字符来模拟大模型的回答问题。

在前端代码中,fetch API的Response.body暴露一个ReadableStream类型的body内容,即一个可读的字节流。同时正是因为是字节流,所以需要通过TextDecoder来解析字节流,之后自行解析后端返回的数据并输出到屏幕。

如果我们优化一下前端UI的话,并且支持了Markdown的解析的话,就可以得到类似AI输出的效果:

再看下Chat GPT的对话,可以看到也是同样的效果:

其实不想自己实现fetch SSE的话,可以用一些第三方库,比如微软的fetch-event-source,实现了类似于原生EventSource的能力,不需要自行解析数据,同时支持POST请求。

优缺点和应用场景

  • 优点:
    • 请求频率低:对比短轮询长轮询来说请求频率很低
    • 实时性很好
    • 在实时性较好的同时,实现上比WebSocket要简单很多(尤其是EventSource
    • EventSource支持自动重连,连接断开后浏览器会自动尝试重新连接,不需要手动维护
  • 缺点:
    • 连接建立后只能服务端到客户端单向通信
    • 通过EventSource来实现的话,只支持GET请求,不支持发送二进制流,只能发送UTF-8编码的文本
    • 通过fetch来实现的话则会有一些复杂性
  • 应用场景:
    • 实时数据流(如股票行情、新闻推送、日志监控等)
    • 需要服务器主动推送但无需客户端响应的场景

五、 WebSocket:双向通信的任意门

SSE作为提案被提出的时间是2008年左右,而与此同时,一个支持双向实时传输的全双工通信方式,WebSocket也被提出来了,并且在RFC 6455中正式发布。

单工、半双工、全双工

  • 单工:数据只能单向传输,一方只能作为发送方,另一方只能作为接收方,比如A→B
  • 半双工:数据可以双向传输,但是不能同时进行,同一时间只能存在一个方向的数据传输,比如A→B或者B→A
  • 全双工:数据可以双向传输,也可以同时进行,即同一时间可以同时存在两个方向的数据传输,A⇄B

技术原理

WebSocket最开始是由HTTP发起的请求,为了兼容现有的浏览器和服务器,它使用标准的HTTP端口(80/443),通过一个特殊的HTTP请求头来升级协议。

客户端:

http 复制代码
GET /chat HTTP/1.1
Host: example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

服务端

http 复制代码
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

握手成功后,连接从HTTP协议升级为WebSocket协议,这个连接会变成一个全双工 (双向)、持久化的连接。

一旦连接建立,客户端和服务器可以随时相互发送数据,不需要再像 HTTP 那样请求-响应模式:

协议视角

从协议的角度看,WebSocket和上面的长短轮询以及SSE完全不同了,WebSocket只是借助HTTP来完成协议的切换,只会在连接建立的时候使用到HTTP协议,一旦协议切换完成,就是一个全新的协议了。

WebSocket和HTTP一样,都是在传输层TCP协议之上的应用层协议,同时因为不是HTTP,所以也没有HTTP的一些同源跨域的限制(所以WebSocket也是实现跨域请求的一种方式,只不过成本有点高)。

代码调用

在使用WebSocket的时候,前端只需要简单地使用WebSocket API即可:

javascript 复制代码
const ws = new WebSocket('wss://example.com/chat');

// 接收消息
ws.onmessage = function(event) {
  const data = JSON.parse(event.data);
  console.log('收到消息:', data);
};

ws.onclose = function(event) {
  console.log('WebSocket连接已关闭');
};

const data = {
  username: '用户1',
  message: '你好,WebSocket!'
};

// 发送消息到服务器
ws.send(JSON.stringify(data));

而服务端,也是用类似的方式进行处理:

javascript 复制代码
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const path = require('path');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.static(path.join(__dirname)));

// 创建HTTP服务器
const server = http.createServer(app);

// 创建WebSocket服务器,将其附加到HTTP服务器
const wss = new WebSocket.Server({ server });

// 监听WebSocket连接事件
wss.on('connection', (ws) => {
  console.log('客户端已连接');
  
  // 发送欢迎消息给客户端
  ws.send(JSON.stringify({ 
    type: 'system', 
    message: '欢迎连接到WebSocket服务器!' 
  }));
  
  // 监听客户端发来的消息
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    console.log('收到消息:', data);
  });
  
  // 监听连接关闭事件
  ws.on('close', () => {
    console.log('客户端已断开连接');
  });
  
  // 监听错误事件
  ws.on('error', (error) => {
    console.error('WebSocket错误:', error);
  });

  // 另外还可以将每个客户端的 ws 实例保存下来,用来实现给所有已连接的客户端进行消息广播等
});

// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`WebSocket服务器运行在 http://localhost:${PORT}`);
});

这里用一个网络聊天室的Demo来看下效果:

协议升级:

数据帧:

服务端日志:

上面的聊天室只是一个很小的WebSocket Demo,实际应用中可能还需要考虑心跳处理和断线重连等,比如服务端或者客户端定时发送一个空的心跳包给对方,如果一定时间内没有收到,就认为对方已经离线。

HTTP/2中的实现

另外,上面提到过在HTTP/1.1 默认启用了Connection: Keep-Alive,在HTTP/2 甚至明确禁止使用头Connection字段,那么上面的WebSocket的握手方式就明显不适用了,即原始WebSocket(RFC 6455)只支持HTTP/1.1。

那怎么解决?有没有办法在HTTP/2中使用WebSocket?

为了让WebSocket可以在HTTP/2上运行,IETF发布了RFC 8441,引入了一种新机制:使用HTTP/2的extended CONNECT方法来升级到WebSocket协议。

  • 客户端:
http 复制代码
:method = CONNECT
:protocol = websocket
:scheme = https
:authority = example.com
  • 服务端:
http 复制代码
:status = 200

这些冒号开头的请求头,(如 :method, :path, :authority 等)是HTTP/2引入的伪首部字段(pseudo-header fields),和传统HTTP/1.x的请求头格式不同。

在 HTTP/2 和 HTTP/3 中,为了更高效地传输协议控制信息,规范规定了以冒号(:)开头的特殊头字段,这些叫作伪首部字段(pseudo-header fields)

它们并不是普通的请求头,而是用来描述请求的核心元素,比如方法、路径、协议等。这主要是为了配合HTTP/2的头部压缩(HPACK)机制以及支持多路复用,所以将这些核心字段标准化成:method:path:authority等"伪头部"字段,直接编码进头部帧

通过上面的请求头和响应头,WebSocket连接就可以建立成功,接下来就是WebSocket协议数据帧的交互了。

为了支持HTTP/2的WebSocket,服务端需要做一些调整:

javascript 复制代码
const http2 = require('http2');
const server = http2.createSecureServer({ 
  key: '...',
  cert: '...' 
});

server.on('stream', (stream, headers) => {
  if (headers[':protocol'] === 'websocket') {
    // 处理 WebSocket 连接
    stream.respond({ ':status': 200 });
    handleWebSocket(stream);
  }
});

function handleWebSocket(stream) {
  stream.on('data', (chunk) => {
    // 处理 WebSocket 数据帧
  });
}

而前端则不需要做特殊处理,那些请求头也和HTTP/1.1一样,由浏览器自动处理:

javascript 复制代码
// 无论 HTTP/1.1 还是 HTTP/2,代码一致
const ws = new WebSocket('wss://example.com/chat');

然而鉴于以下因素:

  • 服务器和浏览器对于RFC 8441的机制,并不是完全支持(比如Nginx不支持,Safari部分支持)
  • 目前的服务器和浏览器都支持HTTP/1.1,在连接时会自动协商使用的HTTP版本
  • WebSocket只是借用HTTP来完成握手和协议切换,切换完成后和HTTP本身是哪个版本就没多大关系了,HTTP2并不会对WebSocket带来特别大的优化

考虑这些因素,目前仍然建议WebSocket走独立的HTTP/1.1路由或者端口。

优缺点和应用场景

  • 优点:
    • 全双工通信,实时性最高
    • 高效(减少HTTP头开销,支持二进制和文本数据)
    • 跨域通信,WebSocket允许跨域通信
  • 缺点:
    • 实现复杂(需处理连接状态、心跳检测等),对于小型应用有些由于复杂
    • 可能被防火墙或代理拦截,一些网络环境中,防火墙和代理可能会阻止WebSocket连接
  • 应用场景:
    • 高频双向交互,如在线游戏、在线文档的协同编辑等
    • 实时聊天、金融交易等低延迟场景

六、 插一嘴关于HTTP的版本

上面提到了好多次不同的HTTP版本,那么我们可能会想到下面几个问题。

1. 查看版本

如何在浏览器里怎么查看一个请求的HTTP版本?

这个很简单,直接看浏览器开发者工具的"网络"面板就可以,右键勾选"协议",就可以直接在协议这一列看到当前的HTTP请求的协议版本

2. 支持多版本

客户端和服务端怎么支持不同的HTTP版本?

对于HTTP/1.1,目前前后端基本上都默认支持了。

对于HTTP2,前端不需要做额外处理,只需要启用HTTPS即可,因为浏览器仅在加密连接下支持HTTP/2。

而在服务端,则需要特殊处理来兼容HTTP/2,以Node实现的服务端为例:

javascript 复制代码
const http = require('http');
const http2 = require('http2');
const fs = require('fs');

// 读取SSL证书
const options = {
  key: fs.readFileSync('私钥路径/server.key'),
  cert: fs.readFileSync('证书路径/server.crt')
};

// 创建HTTP/2服务器
const http2Server = http2.createSecureServer(options, (req, res) => {
  res.writeHead(200);
  res.end('Hello from HTTP/2!');
});

// 可选:创建HTTP/1.1服务器(非必须,ALPN会自动回退)
const httpServer = http.createServer((req, res) => {
  res.writeHead(200);
  res.end('Hello from HTTP/1.1!');
});

// 监听端口(通常HTTP/2用443,HTTP/1.1可共用或分开)
http2Server.listen(443, () => {
  console.log('HTTP/2 server running on port 443');
});
httpServer.listen(80, () => {
  console.log('HTTP/1.1 server running on port 80');
});

如果使用Nginx作为反向代理服务器的话,则还需要在Nginx上修改配置。

3. 确定版本

在请求时,具体使用哪个版本的HTTP是谁来决定的呢?

在请求时,具体使用哪个版本的协议,是由​​客户端和服务端协商​​决定,而非单方强制决定。

步骤 角色 行为
1 客户端(前端) 发起连接时声明支持的协议列表(如 ALPN: h2, http/1.1
2 服务端(后端) 根据自身能力选择最高优先级的协议(如优先选 h2
3 最终协议 双方共同支持的最高版本(如客户端支持 h2,服务端也支持 → 使用 h2

而上面的ALPN,就是​​Application-Layer Protocol Negotiation​​,应用层协议协商,用来建立连接时客户端和服务端协商确认使用哪个版本的协议。

七、 结语

这篇文章从AI的流式回答切入,我们一起了解了从页面刷新,到短轮询长轮询,再到SSE和WebSocket,那么再回到最开始的那个问题,如何优雅实现实时数据更新?

这个问题好像并没有正确的答案,因为没有最完美的技术,只有恰逢其时的选择,每个技术都有自己的优缺点和适用场景,还是要根据自己的业务场景来选择,找到技术和体验的平衡点才是我们要做的事。

参考链接

相关推荐
小小小小宇8 分钟前
TypeScript 中 infer 关键字
前端
__不想说话__22 分钟前
面试官问我React状态管理,我召唤了武林群侠传…
前端·react.js·面试
Cutey91623 分钟前
前端SEO优化方案
前端·javascript
webxin66624 分钟前
带鱼屏页面该怎么适配?看我的
前端
axinawang25 分钟前
SpringBoot整合Java Web三大件
java·前端·spring boot
小old弟28 分钟前
🎨如何动态主题切换 —— css变量🖌️
前端
JiangJiang30 分钟前
🎯 Vue 人看 useReducer:比 useState 更强的状态管理利器!
前端·react.js·面试
iOS阿玮1 小时前
待业的两个月,让我觉得独立开发者才是职场的归宿。
前端·app
八了个戒1 小时前
「数据可视化 D3系列」入门第六章:比例尺的使用
前端·javascript·信息可视化·数据可视化·canvas
少糖研究所1 小时前
ACPA算法详解
前端