当前位置:首页 > 技术 > 正文内容

从零构建Node.js静态资源服务器

访客 技术 2026年10月2日 5

在开始构建服务之前,首先需要初始化项目结构。通过命令行工具创建项目目录并安装必要的依赖包,例如用于终端颜色输出的 chalk、命令行参数解析的 yargs 以及模板引擎 handlebars。

npm init -y
npm install chalk handlebars yargs --save

基础服务架构搭建

使用 Node.js 原生的 http 模块创建 Web 服务器。为了更好的代码组织,我们将服务端的配置与路由处理逻辑分离。配置文件 config.js 负责管理端口号、主机名等基础信息。

const path = require('path');

module.exports = {
  root: process.cwd(),
  hostname: '127.0.0.1',
  port: 8080,
  compression: /\.(html|js|css|md)$/
};

主程序入口 server.js 负责启动 HTTP 服务,并将请求转发给具体的路由处理器。

const http = require('http');
const chalk = require('chalk');
const path = require('path');
const config = require('./config');
const handleRequest = require('./router');

class StaticServer {
  constructor(options) {
    this.conf = { ...config, ...options };
  }

  start() {
    const server = http.createServer(async (req, res) => {
      const targetPath = path.join(this.conf.root, req.url);
      await handleRequest(req, res, targetPath, this.conf);
    });

    server.listen(this.conf.port, this.conf.hostname, () => {
      const address = `http://${this.conf.hostname}:${this.conf.port}`;
      console.log(`Server running at ${chalk.green(address)}`);
    });
  }
}

module.exports = StaticServer;

请求路由与文件处理

核心的请求处理逻辑封装在 router.js 中。为了解决回调地狱问题并提升代码可读性,这里使用了 Node.js 提供的 fs.promises API 配合 async/await 语法。我们需要判断请求路径是文件还是目录,并分别进行处理。

const fs = require('fs').promises;
const path = require('path');
const Handlebars = require('handlebars');
const { getType } = require('./mime');
const { compressStream } = require('./compress');
const { parseRange } = require('./range');
const { checkCache } = require('./cache');
const tplPath = path.join(__dirname, '../templates/dir.tpl');
const templateSource = fs.readFileSync(tplPath, 'utf-8');
const template = Handlebars.compile(templateSource);

module.exports = async function (req, res, filePath, config) {
  try {
    const stats = await fs.stat(filePath);

    if (checkCache(stats, req, res)) {
      res.statusCode = 304;
      res.end();
      return;
    }

    if (stats.isFile()) {
      const mimeType = getType(filePath);
      res.setHeader('Content-Type', mimeType);
      
      const rangeData = parseRange(stats.size, req, res);
      let fileStream;

      if (rangeData) {
        res.statusCode = 206;
        fileStream = fs.createReadStream(filePath, { start: rangeData.start, end: rangeData.end });
      } else {
        res.statusCode = 200;
        fileStream = fs.createReadStream(filePath);
      }

      if (filePath.match(config.compression)) {
        fileStream = compressStream(fileStream, req, res);
      }
      fileStream.pipe(res);

    } else if (stats.isDirectory()) {
      const files = await fs.readdir(filePath);
      res.setHeader('Content-Type', 'text/html');
      const relativeDir = path.relative(config.root, filePath);
      
      const data = {
        title: path.basename(filePath),
        dir: relativeDir ? `/${relativeDir}` : '',
        files: files.map(item => ({
          file: item,
          icon: getType(item)
        }))
      };
      res.end(template(data));
    }
  } catch (error) {
    res.statusCode = 404;
    res.setHeader('Content-Type', 'text/plain');
    res.end(`Resource not found: ${filePath}`);
  }
};

MIME类型映射

为了正确处理不同文件的响应头,我们需要一个简单的工具来判断文件的 MIME 类型。这里创建了一个简化的映射表 mime.js。

const path = require('path');

const typeMap = {
  'js': 'application/javascript',
  'css': 'text/css',
  'html': 'text/html',
  'json': 'application/json',
  'png': 'image/png',
  'jpg': 'image/jpeg',
  'gif': 'image/gif',
  'svg': 'image/svg+xml',
  'txt': 'text/plain',
  'md': 'text/markdown'
};

function getType(filePath) {
  const extension = path.extname(filePath).slice(1).toLowerCase();
  return typeMap[extension] || 'application/octet-stream';
}

module.exports = { getType };

内容压缩处理

利用 zlib 模块对静态资源进行压缩,可以显著减少传输数据量,提升加载速度。compress.js 根据客户端请求头中的 Accept-Encoding 自动选择 Gzip 或 Deflate 算法。

const { createGzip, createDeflate } = require('zlib');

module.exports = function (stream, req, res) {
  const encoding = req.headers['accept-encoding'];
  
  if (!encoding) return stream;

  if (encoding.includes('gzip')) {
    res.setHeader('Content-Encoding', 'gzip');
    return stream.pipe(createGzip());
  } 
   
  if (encoding.includes('deflate')) {
    res.setHeader('Content-Encoding', 'deflate');
    return stream.pipe(createDeflate());
  }

  return stream;
};

支持断点续传

通过解析 HTTP Range 请求头,服务器可以只返回文件的指定部分,这对于大文件下载或视频流播放至关重要。range.js 负责解析范围并设置相应的响应头。

module.exports = function (totalSize, req, res) {
  const rangeHeader = req.headers['range'];
  if (!rangeHeader) return null;

  const parts = rangeHeader.match(/bytes=(\d*)-(\d*)/);
  if (!parts) return null;

  const start = parts[1] ? parseInt(parts[1]) : 0;
  const end = parts[2] ? parseInt(parts[2]) : totalSize - 1;

  if (start >= totalSize || end >= totalSize || start > end) {
    return null;
  }

  res.setHeader('Accept-Ranges', 'bytes');
  res.setHeader('Content-Range', `bytes ${start}-${end}/${totalSize}`);
  res.setHeader('Content-Length', end - start + 1);

  return { start, end };
};

服务端缓存策略

合理的缓存策略能减轻服务器压力。在 cache.js 中,我们实现了基于 Last-Modified 和 ETag 的缓存校验机制。当资源未变化时,服务器返回 304 状态码。

module.exports = function (stats, req, res) {
  const lastModified = stats.mtime.toUTCString();
  const etag = `${stats.size}-${stats.mtime.getTime()}`;

  res.setHeader('Last-Modified', lastModified);
  res.setHeader('ETag', etag);
  res.setHeader('Cache-Control', 'max-age=3600');

  const ifModifiedSince = req.headers['if-modified-since'];
  const ifNoneMatch = req.headers['if-none-match'];

  if (ifModifiedSince && ifModifiedSince !== lastModified) {
    return false;
  }

  if (ifNoneMatch && ifNoneMatch !== etag) {
    return false;
  }

  return true;
};

命令行工具集成

最后,使用 yargs 将程序封装为一个命令行工具(CLI),允许用户自定义端口、根目录等参数。

const yargs = require('yargs/yargs');
const { hideBin } = require('yargs/helpers');
const StaticServer = require('./server');

const argv = yargs(hideBin(process.argv))
  .usage('Usage: $0 [options]')
  .option('port', {
    alias: 'p',
    describe: 'Server port number',
    type: 'number',
    default: 8080
  })
  .option('root', {
    alias: 'r',
    describe: 'Root directory for static files',
    type: 'string',
    default: process.cwd()
  })
  .option('host', {
    alias: 'h',
    describe: 'Server hostname',
    type: 'string',
    default: '127.0.0.1'
  })
  .help()
  .argv;

const server = new StaticServer({
  port: argv.port,
  root: argv.root,
  hostname: argv.host
});

server.start();

通过以上模块的组合,我们已经构建了一个具备目录浏览、文件下载、压缩传输、断点续传以及缓存控制功能的静态资源服务器。

相关文章

Linux crontab 详解

1) crontab 是什么cron 是 Linux 的定时任务守护进程;crontab 是用来编辑/查看“按时间周期执行命令”的表(cron table)。常见两类:用户 crontab:每个用户一份(crontab -e 编辑)系统级 crontab / cron.d:可指定执行用户(/etc/crontab、/etc/cron.d/*)2) crontab 时间...

富文本里可以允许的 HTML 属性

一、所有标签默认允许的安全属性(极少)class        (可选)id           (通常建议禁用)title️ 注意:id 容易被滥用做锚点注入,很多系统直接禁用class 允许的话最好只允许固定前缀(如 editor-*)二、a 标签允许属性<a href="" t...

Mac 安装 Node.js 指南

方法一:通过官网安装包(最简单,适合初学者)如果你只是想快速安装并开始使用,这是最直接的方法。访问 Node.js 官网。页面会显示两个版本:LTS (Recommended For Most Users):长期支持版,最稳定。建议选这个。Current:最新特性版,包含最新功能但可能不够稳定。下载 .pkg 安装包并运行。按照安装向导点击“下一步”即可完成。方法二:使用 Homebrew 安装(...

Dom\HTML_NO_DEFAULT_NS 的副作用:自动加闭合标签

在使用Dom\HTMLDocument时,Dom\HTML_NO_DEFAULT_NS 将禁止在解析过程中设置元素的命名空间, 此设置是为了与DOMDocument向后兼容而存在的。当使用它时,已知的一个副作用就是:自动加闭合标签例如 </img> 为什么会这样?当你使用:Dom\HTML_NO_DEFAULT_NS文档会变成 无命名空间模式,此时内部更接近 XML...

Laravel 事件和监听器创建

在 Laravel 中,使用 Artisan 命令创建 Events(事件) 和 Listeners(监听器) 是非常高效的。你可以通过以下几种方式来实现:1. 手动创建单个 Event如果你只想创建一个事件类,可以使用 make:event 命令:Bashphp artisan make:event UserRegistered执行后,文件将生成在 app/Even...

自定义域名解析神器 dnsmasq

什么是 dnsmasq?dnsmasq 是一个轻量级、功能强大的网络服务工具,专为小型和中等规模网络设计。它是一个综合的网络基础设施解决方案[1]。dnsmasq 能做什么?功能说明应用场景DNS 转发与缓存将 DNS 查询转发到上游服务器(ISP、Google DNS 等),并在本地缓存结果加快 DNS 查询速度,减少外部 DNS 流量本地 DNS解析本地网络设备的主机名,无需编辑&n...

发表评论

访客

◎欢迎参与讨论,请在这里发表您的看法和观点。