node - 内置模块 - http

84 阅读15分钟

当客户端需要数据时,它会发送 HTTP 请求到某台服务器来获取资源。提供这些资源的服务器就叫做 Web 服务器,也可以称为 web server。目前有很多开源的 Web 服务器,比如 NginxApacheTomcat

创建服务器

// app.js => 使用node.js编写的服务器入口文件一般为app.js
// 所以createServer的返回值也一般被命名为 app 或 server
import http from 'node:http'

// 创建服务器
// 在 `createServer` 方法中,我们可以传入一个回调函数。这个回调函数会在每次客户端发送请求时执行
const server = http.createServer((req, res) => {
  // req 是请求对象,包含请求行、请求头、请求体等信息,是可读流
  // res 是响应对象,包含响应的所有信息,比如响应头、响应体等,可以用于向客户端返回数据,是可写流
  res.end('Hello World')
})

// 监听端口
// + 参数是端口号
//   - 1024 以下的端口通常是操作系统分配给特殊服务的,尽量不要使用。
//   - 1024 到 65535 之间的端口可以自由选择。例如,开发中常用的端口有 `3000`、`8000` 等。
// + 回调函数是当服务器启动成功后执行的回调函数
server.listen(3000, () => {
   console.log('服务器已成功开启,端口号:3000 🚀');
})

createServer

通过createServer方法可以创建一个服务器实例,其底层本质是通过new Server来实现的

所以我们可以同时创建多个服务器实例,他们彼此之间是相互独立的

import http from 'node:http'

const server1 = new http.Server((req, res) => {
  res.end('你好')
})

const server2 = http.createServer((req, res) => {
  res.end('你好')
})

server1.listen(3000, () => {
  console.log('服务器1已成功开启,端口号:3000 🚀');
})

server2.listen(4000, () => {
   console.log('服务器2已成功开启,端口号:4000 🚀');
})

参数

http.createServer 方法接收一个回调函数作为参数,该回调函数会在每次客户端发送请求时被调用

这个回调函数接收两个参数:

  • req:请求对象,表示客户端的 HTTP 请求。它包含了请求行、请求头、请求体等信息,并且是一个可读流。
  • res:响应对象,表示服务器的 HTTP 响应。它用于向客户端返回数据,包含响应头、响应体等信息,并且是一个可写流。

listen

listen 方法用于启动 HTTP 服务器,并指定服务器监听的端口号和主机地址。它接收三个参数:

  1. 端口号

    • 范围为 0 到 65535

      • 0-1023:系统保留端口,通常用于特定服务(如 HTTP 的 80,HTTPS 的 443),不建议使用。
      • 1024-65535:用户可用端口,常见的开发端口如 300080008080 等是比较好的选择,便于记忆和标准化。
    • 如果端口号设置为 0 或者省略,系统会自动分配一个可用端口

      • 分配的端口号可以通过 server.address().port 获取。

      • server.listen(0, () => {
           console.log(`服务器已成功开启,端口号:${server.address().port} 🚀`);
        })
        
  2. 主机地址

    • 如果省略,默认行为是监听 0.0.0.0,即所有可用的 IPv4 地址。这意味着服务器可以被本机和外部设备访问。
    • 表示服务器监听的网络地址,常用值包括:
      • localhost:主机名,解析为 127.0.0.1(IPv4)或 ::1(IPv6),仅限本机访问。
      • 127.0.0.1:IPv4 回环地址,仅允许本机访问。
      • 0.0.0.0:监听所有可用的 IPv4 地址,允许外部设备通过网络访问。
    • 如果需要支持 IPv6,可以使用 :: 表示监听所有 IPv6 地址。
  3. 回调函数

    • 在服务器成功启动后执行,用于处理初始化逻辑或打印日志信息。例如:

      server.listen(3000, '127.0.0.1', () => {
       console.log('服务器已成功开启,运行于 127.0.0.1:3000 🚀');
      })
      
    • 回调函数不会处理错误情况,需要通过 server.on('error', callback) 捕获启动错误(如端口被占用)。

      server.on('error', (err) => {
        console.log(err)
      })
      

req

import http from 'node:http'

const server = http.createServer((req, res) => {
  // node是服务器脚本,运行于服务器,所以输出在终端,不在浏览器
  console.log('请求的 URL:', req.url);
  console.log('请求的方法:', req.method);
  console.log('请求头信息:', req.headers);

  res.end('Request received');
});

server.listen(3000, () => {
    console.log('服务器已成功开启,端口号:3000 🚀');
});

输出结果

假设客户端发送了一个 GET 请求,路径为 /home,控制台可能会输出如下内容:

请求的 URL: /home
请求的方法: GET
请求头信息: {
    host: 'localhost:8000',
    connection: 'keep-alive',
    'user-agent': 'PostmanRuntime/7.29.0',
    accept: '*/*'
    # ...
}

当使用 createServer 创建一个 HTTP 服务器时,传入的回调函数会在接收到任何 URL 请求时触发。

因此,当我们访问首页 / 时,浏览器可能会额外发送对 /favicon.ico 的请求,这意味着该回调函数可能会被触发多次。

而具体是否会被调用多次,是由浏览器的行为自行决定的

区分请求路径和方法

const server = http.createServer((req, res) => {
  const url = req.url;
  const method = req.method;

  // 默认情况下,node没有指定编码,客户端会认为是ASCII编码,所以会导致中文乱码
  // 所以需要设置响应头,指定内容类型和编码, 告诉客户端返回的内容是utf-8编码的html
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');

  if (url === '/login' && method === 'POST') {
      res.end('登录成功');
  } else if (url === '/login') {
      res.end('不支持的请求方式,请使用 POST');
  } else if (url === '/product') {
      res.end('商品列表');
  } else {
      res.end('不存在的URL,请检查路径地址');
  }
});

解析请求参数

import http from 'node:http'
import url from 'node:url'
import querystring from 'node:querystring'

// 假设请求的URL为:http://localhost:3000/home?offset=10&size=20
const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // 解析 URL => url内置模块使用和浏览器的URL使用是一致的
  const urlInfo = url.parse(req.url); // 解析 URL

  // 解析查询字符串 => 将查询字符串转换为对象 「 值为字符串类型值 」
  const query = querystring.parse(urlInfo.query); // 解析查询字符串

  // 注意:req.url 是 path + query,不是完整的URL
  console.log(req.url) // /home?offset=10&size=20
  console.log('Path:', urlInfo); // 输出路径
  console.log('Query:', query); // 输出查询字符串解析结果

  if (urlInfo.pathname === '/home') {
      const offset = query.offset ?? 0;
      const size = query.size ?? 10;

      res.end(`偏移量: ${offset}, 数据大小: ${size}`);
  } else {
      res.end('未知路径');
  }
});

server.listen(3000, () => {
    console.log('服务器已成功开启,端口号:3000 🚀');
});

自 Node.js v10.0.0 起,官方开始推荐使用原生的 URLURLSearchParams 接口来解析和处理 URL,而不是使用传统的 urlquerystring 模块。

尽管这些旧模块仍然可以使用,但它们已经被标记为 Legacy(遗留模块),不推荐在新的代码中使用。

import http from 'node:http'

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // 构建完整URL
  const urlInfo = new URL(req.url, `http://${req.headers.host}`);
  const params = urlInfo.searchParams;

  console.log('Path:', urlInfo.pathname); // 输出路径

  // searchParams 是 URLSearchParams 对象,是一个可迭代方法
  // 通过 Object.fromEntries 将可迭代对象转换为对象
  console.log('Query:', Object.fromEntries(params)); // 输出查询字符串解析结果

  if (urlInfo.pathname === '/home') {
      const offset = params.get('offset') || 0;
      const size = params.get('size') || 10;

      res.end(`偏移量: ${offset}, 数据大小: ${size}`);
  } else {
      res.end('未知路径');
  }
});

server.listen(3000, () => {
    console.log('服务器已成功开启,端口号:3000 🚀');
});

解析请求体

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // method 不区分大小写,内部会自动全部转大写
  if (req.url === '/login' && req.method === 'POST') {
    let body = ''

    // req 是一个可读流,body 是流中的数据
    req.on('data', (chunk) => {
      // chunk是buffer类型数据,在字符串拼接时会自动转字符串类型值
      // 也可以通过req.setEncoding('utf8')或手动调用toString方法转字符串
      body += chunk
    })

    req.on('end', () => {
      // body是JSON格式字符串类型值,解析为对象
      const info = JSON.parse(body)
      if (info.username === 'admin' && info.password === '123456') {
        res.end('登录成功')
      } else {
        res.end('登录失败')
      }
    })
  } else {
    res.end('Not Found')
  }
})

常见请求头

  1. Content-Type:
    表示请求数据的类型,例如:

    • application/x-www-form-urlencoded

    • application/json

    • text/plain

    • text/html

    • multipart/form-data(用于二进制数据上传 和 表单上传)

  2. Content-Length:
    表示请求数据的长度。对于文件上传,数据可能较大,无法一次性通过 data 事件读取完毕,需要多次监听 data 事件。当读取的数据长度达到 Content-Length 的值时,说明数据已经读取完毕。

    浏览器会自动计算Content-Length的值

  3. Connection: Keep-Alive:
    在 HTTP/1.1 中,默认所有连接都是 Keep-Alive,不需要额外设置。

    在 Node.js 中,连接默认保持 5 秒钟。如果在此期间发送多次请求,使用的是同一个连接,从而提高访问性能。

  4. Accept-Encoding:
    指定客户端可以接受的压缩格式,例如:

    • gzip
    • deflate
    • br

    如果服务器有客户端可以接受的压缩格式类型文件,服务器会返回压缩后的文件,客户端会自动解压,提高传输效率。如果不存在,则返回非压缩格式文件

  5. Accept:
    指定客户端接受的文件格式。服务器可以根据此信息设置响应数据的格式,例如 UTF-8 编码来正确解析中文。

  6. User-Agent:
    包含客户端的信息,例如浏览器或 Postman 的版本。

res

import http from 'node:http'

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // res本质是可写流,所以可以通过write和end方法返回数据

  // 响应方式一:使用 write 方法写入数据
  res.write('Hello World');
  res.write('哈哈');
  // 响应方式二:使用 end 方法结束写入
  //   + res进行了特殊处理,不能通过close关闭写入流,只能通过end方法进行关闭
  //   + 如果我们没有调用 `end` 方法,客户端会一直等待响应结束
  //   + 这也就是为什么推荐客户端设置请求超时时间,避免一直等待
  res.end('本次写入已结束');
});

server.listen(3000, () => {
    console.log('服务器已成功开启,端口号:3000 🚀');
});

此时浏览器会显示 => Hello World哈哈本次写入已结束

状态码

响应状态码,称为 HTTP 状态码。它是用来表示这次响应状态的一个数字代码。

HTTP 状态码有很多种,我们可以根据不同的情况,向客户端返回不同的状态码。

客户端可以根据服务器返回的状态码,判断当前请求的结果是什么。

常见的状态码及其含义:

状态码状态描述信息说明
200OK客户端请求成功,服务器返回所请求的数据。
201CreatedPOST 请求成功,服务器创建了新的资源。
204No Content请求成功,但服务器没有返回任何内容。
206Partial Content服务器成功处理了部分请求(如分块下载)。
301Moved Permanently请求资源的 URL 已永久修改,响应中会给出新的 URL。
302Found请求资源的 URL 已临时修改,响应中会给出新的 URL。
304Not Modified资源未被修改,客户端可使用缓存版本。
307Temporary Redirect请求资源临时移动至新位置,HTTP 方法保持不变。
308Permanent Redirect请求资源永久移动至新位置,HTTP 方法保持不变。
400Bad Request客户端请求有误,服务器无法处理。
401Unauthorized未授权的请求,客户端需要提供认证信息。
403Forbidden客户端没有权限访问资源,即使已认证也被拒绝。
404Not Found服务器无法找到请求的资源。
405Method Not Allowed请求方法不被允许(如使用了不支持的 HTTP 方法)。
408Request Timeout请求超时,客户端未在规定时间内发送完整请求。
413Payload Too Large请求实体过大,服务器无法处理。
414URI Too Long请求的 URI 太长,服务器无法处理。
429Too Many Requests客户端发送的请求次数过多,触发了速率限制。
500Internal Server Error服务器遇到了未知错误,无法处理请求。
501Not Implemented服务器不支持请求的方法。
502Bad Gateway网关或代理服务器收到无效响应。
503Service Unavailable服务器暂时不可用(超载或维护)。
504Gateway Timeout网关或代理服务器超时,未收到上游服务器响应。
505HTTP Version Not Supported服务器不支持请求的 HTTP 版本。

网关

网关是请求的入口,接收客户端的请求并转发到不同的后端服务器,后端服务器处理请求后返回结果,网关再将结果返回给客户端。它充当一个中间层,连接客户端(外部)和服务器(内部)。

img

重定向

307308 是 HTTP/1.1 标准中引入的,弥补了 302301 在方法改变上的不明确性。

状态码请求方法是否改变是否永久改变
301改变 ( 新地址使用 )永久重定向
客户端会缓存新URL,下次直接使用新URL进行访问
302改变 ( 新地址使用 GET )临时重定向
客户端不会缓存新URL,下次依旧使用旧URL进行访问
307不改变(保持原方法)临时重定向
客户端不会缓存新URL,下次依旧使用旧URL进行访问
308不改变(保持原方法)永久重定向
客户端会缓存新URL,下次直接使用新URL进行访问

设置状态码

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // 设置状态码 和 状态消息
  res.statusCode = 201
  res.statusMessage = 'Created'

  res.end('success');
});
const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8')

  // 在设置状态码的同时,定义其他的响应头信息。
  // 参数一 状态码
  // 参数二 状态消息 「 可以省略 」
  // 参数三 响应头信息 「 可以省略 」
  res.writeHead(201, 'Created', {
    'Content-Type': 'text/html; charset=utf-8'
  })

  res.end('success');
});

响应头

当服务端返回中文内容,如果服务器没有明确指定返回结果的编码类型

  • postman => 使用utf-8进行解析
  • 浏览器 => 使用ASCII码进行解析

因此,在正常情况下,当我们给客户端返回结果时,需要明确告诉它返回结果的格式和编码类型

设置响应头部信息有两种方式:

  • 方式一

    • 我们可以通过 res.setHeader 方法来设置
    res.setHeader('Content-Type', 'application/json; charset=UTF-8');
    
  • 方式二

    • 使用 res.writeHead 方法,同时设置状态码和头部信息
    res.writeHead(200, {
      // 此时可以设置多个响应头
      'Content-Type': 'application/json; charset=UTF-8'
    });
    

网络请求

Axios既可以在浏览器使用,也可以在node环境中使用。

  • 在浏览器环境中,axios是基于XMLHttpRequest封装的
  • 在Node.js中,axios是基于http模块封装的

所以,HTTP 模块除了能搭建服务器,还可以主动发送网络请求。

import http from 'node:http'

// 使用 http.get 方法发送GET请求
http.get('http://httpbin.org/get', (res) => {
  let data = ''

  // 回调函数中,res 是可读流,所以可以通过 on 方法监听事件
  res.on('data', (chunk) => {
    data += chunk
  })

  // 当数据流结束时,会触发 end 事件
  res.on('end', () => {
    console.log(data)
  })
})

发送其它类型方法,必须通过request方法。因为 HTTP 模块没有直接提供 post 等系列方法。

import http from 'node:http'

// 配置对象
const options = {
  hostname: 'httpbin.org', // 主机名
  path: '/post', // 请求路径
  port: 80,  // 端口号
  method: 'POST', // 请求方法
  headers: {
    'Content-Type': 'application/json' // 请求头
  }
};

// const 请求写入流 = http.request(配置对象, 处理响应读取流的回调)
const req = http.request(options, (res) => {
  let data = '';

  res.on('data', (chunk) => {
    data += chunk;
  });

  res.on('end', () => {
    console.log('Response:', data);
  });
});

// 发送请求体数据
//   + JSON是通用标准,但不同编程语言对JSON的处理不尽相同
//   + 所以网络传输过程中,使用的是JSON格式字符串,而不是JSON对象
req.write(JSON.stringify({ message: 'Hello, Server!' }));
// 告诉服务器,请求体数据已发送完毕
req.end();

options 可选配置项

配置项作用示例
hostname指定目标服务器的主机名或 IP 地址。'httpbin.org'
port指定目标服务器的端口号。80443
path指定请求的路径,包括查询字符串(如需要)。'/post''/get?id=123'
method指定 HTTP 请求方法(如 GET, POST, PUT, DELETE 等)。'POST'
headers指定请求头,用于传递额外的元信息。{ 'Content-Type': 'application/json' }
timeout指定请求的超时时间(以毫秒为单位)。5000
protocol指定请求使用的协议(默认为 http:https:)。'http:''https:'

示例 - 文件上传

multipart/form-data 是一种 HTTP 请求的编码方式,主要用于上传文件或提交表单数据。

它将数据分隔成多个部分,每部分包含自己的头信息(如 Content-DispositionContent-Type),并通过一个边界字符串(boundary)将各部分分隔开。这种格式允许文件和表单字段同时提交。

----------------------------292794409779591642750678
Content-Disposition: form-data; name="avatar"; filename="node.png"
Content-Type: image/png

�PNG


IHDR	`���.ZtEXtSoftwareAdobe ImageReadyq�e<(iTXtXML:com.adobe.xmp<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?> <x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c138 79.159824, 2016/09/14-01:09:01        "> <rdf:RDF 

# ....
----------------------------292794409779591642750678--

在本例中

  • --------------------------292794409779591642750678 是自动生成的边界字符串,用于分隔数据块。

    • 这个边界字符串,假设叫boundary
    • 则 实际使用的分割符为 --<boundary>
    • 使用--<boundary>--表示传输完毕
  • Content-Disposition 指定了字段名(如 "avatar")和文件名(如 "node.png")。

  • Content-Type 表示文件的 MIME 类型(如 image/png)。

  • 文件的实际内容以二进制数据的形式紧随其后。

为了存储上传的文件,必须从 multipart/form-data 格式中剔除字段名、头信息等不必要的数据,只保留文件的二进制内容。

:: code-group

import http from 'node:http'
import fsPromise from 'node:fs/promises'
import fs from 'node:fs'
import path from 'node:path'

// 将数组分割成多个子数组 「 默认每个子数组大小为 65536(64KB) 」
function splitArray(array, chunkSize = 65536) {
  const result = [];
  for (let i = 0; i < array.length; i += chunkSize) {
      result.push(array.slice(i, i + chunkSize)); // 分割数组
  }
  return result;
}

const server = http.createServer(async (req, res) => {
  if (req.url === '/upload' && req.method === 'POST') {
    const allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf']
    const buffers = []
    let writeStream = null

    // 由于数据是以分段形式发送的,因此需要将每个数据块存入 Buffer 数组。
    // 上传文件的时候,数据是分段发送的,每一个数据分段并不代表数据内容完整
    // 数据分段内容不完整时,直接转换为字符串会导致字符编码器"猜测"字符的含义,而这种猜测可能是错误的,从而产生乱码或数据偏差。
    // 这种情况下,再将字符串转换回 Buffer 时,数据可能会丢失或被破坏,从而无法正常解析。
    // 虽然原则上,完整buffer转字符串,再转回buffer,是相等的 " 可以通过 buf.equals(buf2) 验证 "
    req.on('data', chunk =>  buffers.push(chunk))

    req.on('end', async () => {
      try {
        const contentType = req.headers['content-type']

        // 解析 boundary
        const boundaryMatch = contentType.match(/boundary=(.+)$/)
        const boundary = Buffer.from('--' + boundaryMatch[1])
        const endBoundary = Buffer.from('--' + boundaryMatch[1] + '--')

        // 将所有数据块合并成一个完整的 Buffer
        const bodyBuffer = Buffer.concat(buffers)

        // 找到 boundary 的起始位置
        //   - +2 是因为boundary后边是换行符 \r\n 「 2个字节 」
        //   - start 是 boundary后 的起始位置 「 即boundary后的第二行 」
        //   - 不能使用trim方法,因为trim是字符串方法,不是buffer方法。使用时会隐式转换为字符串,从而产生乱码 「 数据误差 」
        let start = bodyBuffer.indexOf(boundary) + boundary.length + 2 // 跳过 \r\n
        // 找到 endBoundary 的起始位置
        let end = bodyBuffer.indexOf(endBoundary)

        while (start < end) {
          // 找下一个 boundary
          const nextBoundary = bodyBuffer.indexOf(boundary, start)
          let partEnd = nextBoundary === -1 ? end : nextBoundary - 2 // -2 去掉前面的 \r\n

          // 获取当前数据分段 => subarray(start, end) 基于 [srart, end) 生成一个新缓存区 " 类似于数组的 slice 方法 "
          const part = Buffer.from(bodyBuffer.subarray(start, partEnd))

          // 解析 header 和内容 => 根据 boundary 分割的文件,header 和 content 之间会有一段 \r\n\r\n " 4个字节 ",基于此进行分割
          const headerEnd = part.indexOf('\r\n\r\n')
          if (headerEnd === -1) break
          const header = part.subarray(0, headerEnd).toString()
          const content = part.subarray(headerEnd + 4)

          const filenameMatch = header.match(/filename=\"(.+?)\"/)
          const mimeMatch = header.match(/Content-Type: (.+)/)

          if (filenameMatch && mimeMatch) {
            const filename = filenameMatch[1]
            const mimetype = mimeMatch[1].trim()
            if (!allowedMimeTypes.includes(mimetype)) {
              start = nextBoundary === -1 ? end : nextBoundary + boundary.length + 2
              continue
            }

            // 确保 uploads 目录存在
            try {
              await fsPromise.access('./uploads')
            } catch {
              await fsPromise.mkdir('./uploads')
            }

            // 获取文件扩展名
            const fileExtname = path.extname(filename)
            const fileName = path.basename(filename, fileExtname)
            const filePath = path.join('./uploads', `${fileName}-${Date.now()}${fileExtname}`)

            // 手动分块
            const chunks = splitArray(content)
            writeStream = fs.createWriteStream(filePath)

            // 模拟流式写入
            chunks.forEach(chunk => {
              writeStream.write(chunk)
            })

            writeStream.end()
          }

          start = nextBoundary === -1 ? end : nextBoundary + boundary.length + 2
        }
        res.end('上传成功')
      } catch (error) {
        res.statusCode = 500
        res.end('上传失败')
      }
    })
  } else {
    res.end('Not Found')
  }
})

server.listen(3000, () => {
  console.log('服务器已成功开启,端口号:3000 🚀')
})
import http from 'node:http'
import fs from 'node:fs'

function splitArray(array, chunkSize = 65536) {
  const result = [];
  for (let i = 0; i < array.length; i += chunkSize) {
      result.push(array.slice(i, i + chunkSize));
  }
  return result;
}

const server = http.createServer((req, res) => {
  if (req.url === '/upload' && req.method === 'POST') {
    // 将req流当做二进制字符串进行解析 => 此时,一个字节转一个字符,不存在猜测问题,因此可以正常操作二进制流
    req.setEncoding('binary')

    const allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf']
    let data = ''

    req.on('data', (chunk) => {
      data += chunk
    })

    req.on('end', () => {
      const boundaryStr = req.headers['content-type'].split('=')[1].trim()
      const boundary = '--' + boundaryStr
      const endBoundary = boundary + '--'

      const boundaryIndex = data.indexOf(boundary) + boundary.length
      const endBoundaryIndex = data.indexOf(endBoundary)

      const fileData = data.slice(boundaryIndex, endBoundaryIndex).trim()

      const mimeType = fileData.match(/Content-Type: (.+)/)[1]

      if (!allowedMimeTypes.includes(mimeType)) {
        res.end('不支持的文件类型')
        return
      }

      const [header, content] = fileData.split(`Content-Type: ${mimeType}`).map(item => item.trim())
      const filename = header.match(/filename="(.+?)"/)[1]
      const [file, ext] = filename.split('.')

      const writeStream = fs.createWriteStream(`./uploads/${file}-${Date.now()}.${ext}`)

      const chunks = splitArray(content, 65536)

      for (const chunk of chunks) {
        // 以二进制流的形式输入
        writeStream.write(chunk, 'binary')
      }

      writeStream.end()

      res.end('上传成功')
    })
  } else {
    res.end('Not Found')
  }
})

server.listen(3000, () => {
  console.log('服务器已成功开启,端口号:3000 🚀')
})

::

原生操作非常的麻烦,所以我们一般使用专门的库来解析 multipart/form-data 格式。

  • busboy 是一个轻量级的库,专门用于解析 multipart/form-data 格式,并且可以直接与原生 http 模块配合使用。
  • multer 是专门为 ExpressKoa 等框架设计的文件上传中间件,无法直接与原生 http 模块配合使用。

具体使用,查看官方文档即可

import http from 'node:http'
import fs from 'node:fs'
// busboy => 勤杂工
import Busboy from 'busboy'

const server = http.createServer((req, res) => {
  if (req.url === '/upload' && req.method === 'POST') {
    const busboy = Busboy({ headers: req.headers })

    // file是文件上传事件,当且仅当formData中包含文件时,才会触发
    //  + name => 字段名
    //  + file => 文件流
    //  + info => 文件信息 「 包含了文件名,文件大小,MIME类型等 」

    // busbuy还有其余事件 如:
    //  + 'field'  => 当formData中包含普通字段时触发
    //  + 'close'  => 当busboy解析完毕关闭时触发

    busboy.on('file', (name, file, info) => {
      const allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf'];
      if (!info.filename || !allowedMimeTypes.includes(info.mimeType)) {
        // 如果没有文件名或文件类型不支持,跳过文件
        // resume <=> 恢复,继续,重新回到,重返回 | 概要,摘要
        return file.resume();
      }

      // 创建写入流 并指定写入位置
      //   1. 如果文件存在,则覆盖
      //   2. 如果写入目录不存在,就报错
      const writeStream = fs.createWriteStream(`./uploads/${info.filename}`)
      // 在读取流和写入流之间建立一个管道
      file.pipe(writeStream)

      // 写入流上传完毕,就是图片上传完毕
      writeStream.on('finish', () => {
        res.end('上传成功')
      })
    })

    // 将req流数据交给busboy进行处理
    req.pipe(busboy)
  } else {
    res.end('Not Found')
  }
})

server.listen(3000, () => {
  console.log('服务器已成功开启,端口号:3000 🚀')
})