用node从0到1实现一个文件服务器

773 阅读16分钟

在日常的工作中,有时需要一个http服务来访问我们的静态资源来测试的情况,也有时需要在一个庞大的文件夹里找某个文件,一层一层点还是比较麻烦。当然市面也有很多类似的解决方案,例如搭建NGINX本地服务来访问静态资源,包括http-server等

解决需求痛点

  • 实现静态资源服务器
  • 实现在任意文件夹开启服务并可视化界面访问内部资源

搭建工程

以GitHub为例

截屏2021-08-26 上午11.10.52.png 记得勾上下面三个选项目,会自动初始化几个配置文件,省得自己敲

拉项目到本地,紧接着开始配置eslinthusky

代码规范

随着前端发展愈发的规范后,项目的规模也会越来越庞大,涉及到的开发人员也越来越多,一个项目多人协作的场景也越来越多,代码的规范就是一个大问题了,光靠口头的强调远远不够,需要有一套规范来约束,节省人工成本,效率还高。

下面我们介绍一套规范组合以及相关的配置(EditorConfig + Prettier + ESlint

  • 解决团队之间代码不规范导致的可读性差、可维护性差的问题
  • 解决团队成员使用不同编辑器导致代码规范不统一的问题
  • 提前发下代码风格问题,给出相应提示,及时修复
  • 减少代码审查过程中反反复复的修改,节约时间
  • 自动格式化,统一代码风格
集成 EditorConfig 配置

EditorConfig 有助于为不同 IDE 编辑器上处理同一项目的多个开发人员维护一致的代码风格。

官网:editorconfig.org

在项目的根目录下创建 .editorconfig 文件:

# Editor configuration, see http://editorconfig.org

# 表示是最顶层的 EditorConfig 配置文件
root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行

[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off
trim_trailing_whitespace = false

注意:

  • VSCode 使用 EditorConfig 需要去插件市场下载插件 EditorConfig VS Code

截屏2021-06-13 下午9.25.52.png

  • JetBrains 系列 (WebStorm、IntelliJ IDEA)则不用额外安装插件,可直接使用 EditorConfig 配置。
集成 Prettier 配置

Prettier 是一款强大的代码格式化工具,支持JavaScript、Typescript、Css、Scss、Less、JSX、Angular、Vue、GraphQL、JSON、Markdown等,基本上前端能用到的文件格式都可以搞定,是当下最流行的格式化工具。

官网:prettier.io/

  1. 安装Prettier

    npm i prettier -D

  2. 创建 Prettier 配置文件

    Prettier 支持多种格式的配置文件,比如 .json、.yml、yaml、.js等。 在根目录下创建 .prettierrc 文件

  3. 配置 .prettierrc 在本项目中,我们进行如下简单配置,关于更多的配置项信息,查阅官网

{
  "useTabs": false,
  "tabWidth": 2,
  "printWidth": 100,
  "singleQuote": true,
  "trailingComma": "none",
  "bracketSpacing": true,
  "semi": false
}
  1. Prettier安装配置好之后,就能使用命令来格式化代码
# 格式化所有文件 (. 表示所有文件)
npx prettier --write .

注意:

  • VSCode 编辑器使用 Prettier 配置需要下载插件 Prettier - Code formatter

截屏2021-06-13 下午9.43.35.png

  • JetBrains 系列 (WebStorm、IntelliJ IDEA)则不用额外安装插件,可直接使用 EditorConfig 配置。

Prettier 配置好以后,在使用 VSCode 或 WebStorm 等编辑器的格式化功能时,编辑器就会按照 Prettier 配置文件的规则来进行格式化,避免了因为大家编辑器配置不一样而导致格式化后的代码风格不统一的问题。

集成 ESlint 配置

ESLint 是一款用于查找并报告代码中问题的工具,并且支持部分问题自动修复。其核心是通过对代码解析得到的 AST(Abstract Syntax Tree 抽象语法树)进行模式匹配,来分析代码达到检查代码质量和风格问题的能力。

正如前面我们提到的因团队成员之间编程能力和编码习惯不同所造成的代码质量问题,我们使用 ESLint 来解决,一边写代码一边查找问题,如果发现错误,就给出规则提示,并且自动修复,长期下去,可以促使团队成员往同一种编码风格靠拢。

  1. 安装 ESLint

    可以全局或者本地安装,作者推荐本地安装(只在当前项目中安装)。

    npm i eslint -D

  2. 配置ESLint

    ESLint 安装成功后,执行 npx eslint --init,然后按照终端操作提示完成一系列设置来创建配置文件。

    插件
    Airbnb JavaScript Style Guide
    JavaScript Standard Style
    Google JavaScript Style Guide

操作:

  • How would you like to use ESLint? (你想如何使用 ESLint?)

image.png 我们这里选择 To check syntax, find problems, and enforce code style(检查语法、发现问题并强制执行代码风格)

  • What type of modules does your project use?(你的项目使用哪种类型的模块?)

image.png

我们这里选择 JavaScript modules (import/export)

  • Which framework does your project use? (你的项目使用哪种框架?)

image.png

我们这里选择 Vue.js

  • Does your project use TypeScript?(你的项目是否使用 TypeScript?)

image.png

我们这里选择 Yes

  • Where does your code run?(你的代码在哪里运行?)

image.png 我们这里选择 Browser 和 Node(按空格键进行选择,选完按回车键确定)

  • How would you like to define a style for your project?(你想怎样为你的项目定义风格?)

image.png

我们这里选择 Use a popular style guide(使用一种流行的风格指南)

  • Which style guide do you want to follow?(你想遵循哪一种风格指南?)

image.png

我们这里选择 Airbnb: github.com/airbnb/java…

ESLint 为我们列出了三种社区流行的 JavaScript 风格指南,分别是 Airbnb、Standard、Google。

这三份风格指南都是由众多大佬根据多年开发经验编写,足够优秀,全球很多大小公司都在使用。我们选用 GitHub 上 star 最多的 Airbnb,免去繁琐的配置 ESLint 规则时间,然后让团队成员去学习 Airbnb JavaScript 风格指南即可。

此时,我们在 ESLint 配置了 Airbnb JavaScript 规则,在编码时,所有不符合 Airbnb 风格的代码,编辑器都会给出提示,并且可以自动修复。

这里作者不建议大家去自由配置 ESLint 规则,相信我,这三份 JavaScript 代码风格指南值得我们反复学习,掌握后,编程能力能上一大台阶。

  • 这里作者不建议大家去自由配置 ESLint 规则,相信我,这三份 JavaScript 代码风格指南值得我们反复学习,掌握后,编程能力能上一大台阶。

image.png 我们这里选择 JavaScript

  • Would you like to install them now with npm?(你想现在就用 NPM 安装它们吗?)

image.png

根据上面的选择,ESLint 会自动去查找缺失的依赖,我们这里选择 Yes,使用 NPM 下载安装这些依赖包。

注意:如果自动安装依赖失败,那么需要手动安装

npm i @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-airbnb-base eslint-plugin-import eslint-plugin-vue -D
  1. ESlint 配置文件 .eslintrc.js 在上一步操作完成后,会在项目根目录下自动生成 .eslintrc.js 配置文件:
module.exports = {
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  extends: ["plugin:vue/essential", "airbnb-base"],
  parserOptions: {
    ecmaVersion: 12,
    parser: "@typescript-eslint/parser",
    sourceType: "module",
  },
  plugins: ["vue", "@typescript-eslint"],
  rules: {},
};

根据项目实际情况,如果我们有额外的 ESLint 规则,也在此文件中追加。

注意:

  • VSCode 使用 ESLint 配置文件需要去插件市场下载插件 ESLint 。

image.png

  • JetBrains 系列(WebStorm、IntelliJ IDEA 等)则不用额外安装插件。 配置好以后,我们在 VSCode 或 WebStorm 等编辑器中开启 ESLin,写代码时,ESLint 就会按照我们配置的规则来进行实时代码检查,发现问题会给出对应错误提示和修复方案。

如图:

  • VScode

image.png

  • WebStorm

image.png

虽然,现在编辑器已经给出错误提示和修复方案,但需要我们一个一个去点击修复,还是挺麻烦的。很简单,我们只需设置编辑器保存文件时自动执行 eslint --fix 命令进行代码风格修复。

  • VSCode 在 settings.json 设置文件中,增加以下代码:
"editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
 }
  • WebStorm 打开设置窗口,按如下操作,最后点击 Apply -> OK。

image.png

解决 Prettier 和 ESLint 的冲突

通常大家会在项目中根据实际情况添加一些额外的 ESLint 和 Prettier 配置规则,难免会存在规则冲突情况。

本项目中的 ESLint 配置中使用了 Airbnb JavaScript 风格指南校验,其规则之一是代码结束后面要加分号,而我们在 Prettier 配置文件中加了代码结束后面不加分号的配置项,这样就有冲突了,会出现用 Prettier 格式化后的代码,ESLint 检测到格式有问题的,从而抛出错误提示。 解决两者冲突问题,需要用到 eslint-plugin-prettier 和 eslint-config-prettier。

  • eslint-plugin-prettier 将 Prettier 的规则设置到 ESLint 的规则中。
  • eslint-config-prettier 关闭 ESLint 中与 Prettier 中会发生冲突的规则。 最后形成优先级:Prettier 配置规则 > ESLint 配置规则。
  • 安装插件
npm i eslint-plugin-prettier eslint-config-prettier -D
  • 在 .eslintrc.js 添加 prettier 插件
module.exports = {
  ...
  extends: [
    'plugin:vue/essential',
    'airbnb-base',
    'plugin:prettier/recommended' // 添加 prettier 插件
  ],
  ...
}

这样,我们在执行 eslint --fix 命令时,ESLint 就会按照 Prettier 的配置规则来格式化代码,轻松解决二者冲突问题。

集成 husky 和 lint-staged

我们在项目中已集成 ESLint 和 Prettier,在编码时,这些工具可以对我们写的代码进行实时校验,在一定程度上能有效规范我们写的代码,但团队可能会有些人觉得这些条条框框的限制很麻烦,选择视“提示”而不见,依旧按自己的一套风格来写代码,或者干脆禁用掉这些工具,开发完成就直接把代码提交到了仓库,日积月累,ESLint 也就形同虚设。

所以,我们还需要做一些限制,让没通过 ESLint 检测和修复的代码禁止提交,从而保证仓库代码都是符合规范的。

为了解决这个问题,我们需要用到 Git Hook,在本地执行 git commit 的时候,就对所提交的代码进行 ESLint 检测和修复(即执行 eslint --fix),如果这些代码没通过 ESLint 规则校验,则禁止提交。

实现这一功能,我们借助 husky + lint-staged

husky —— Git Hook 工具,可以设置在 git 各个阶段(pre-commit、commit-msg、pre-push 等)触发我们的命令。

lint-staged —— 在 git 暂存的文件上运行 linters。

配置 husky
  • 自动配置(推荐) 使用 husky-init 命令快速在项目初始化一个 husky 配置。

npx husky-init && npm install

这行命令做了四件事:

  1. 安装 husky 到开发依赖

image.png 2. 在项目根目录下创建 .husky 目录

image.png 3. 在 .husky 目录创建 pre-commit hook,并初始化 pre-commit 命令为 npm test

image.png 4. 修改 package.json 的 scripts,增加 "prepare": "husky install"

image.png 到这里,husky 配置完毕,现在我们来使用它:

husky 包含很多 hook(钩子),常用有:pre-commit、commit-msg、pre-push。这里,我们使用 pre-commit 来触发 ESLint 命令。

修改 .husky/pre-commit hook 文件的触发命令:

eslint --fix ./src --ext .vue,.js,.ts

image.png

上面这个 pre-commit hook 文件的作用是:当我们执行 git commit -m "xxx" 时,会先对 src 目录下所有的 .vue、.js、.ts 文件执行 eslint --fix 命令,如果 ESLint 通过,成功 commit,否则终止 commit

但是又存在一个问题:有时候我们明明只改动了一两个文件,却要对所有的文件执行 eslint --fix。假如这是一个历史项目,我们在中途配置了 ESLint 规则,那么在提交代码时,也会对其他未修改的“历史”文件都进行检查,可能会造成大量文件出现 ESLint 错误,显然不是我们想要的结果。

我们要做到只用 ESLint 修复自己此次写的代码,而不去影响其他的代码。所以我们还需借助一个神奇的工具 lint-staged 。

配置 lint-staged

lint-staged 这个工具一般结合 husky 来使用,它可以让 husky 的 hook 触发的命令只作用于 git add 那些文件(即 git 暂存区的文件),而不会影响到其他文件。

接下来,我们使用 lint-staged 继续优化项目。

  1. 安装 lint-staged
npm i lint-staged -D
  1. package.json里增加 lint-staged 配置项

image.png

"lint-staged": {
  "*.{vue,js,ts}": "eslint --fix"
},

这行命令表示:只对 git 暂存区的 .vue、.js、.ts 文件执行 eslint --fix。

  1. 修改 .husky/pre-commit hook 的触发命令为:npx lint-staged

image.png 至此,husky 和 lint-staged 组合配置完成。

现在我们提交代码时就会变成这样:

假如我们修改了 scr 目录下的 test-1.jstest-2.tstest-3.md 文件,然后 git add ./src/,最后 git commit -m "test...",这时候就会只对 test-1.js、test-2.ts 这两个文件执行 eslint --fix。如果 ESLint 通过,成功提交,否则终止提交。从而保证了我们提交到 Git 仓库的代码都是规范的。

image.png

  • 提交前 test-1.jstest-2.ts

image.png

  • 提交后 test-1.jstest-2.ts 自动修复代码格式

image.png

无论写代码还是做其他事情,都应该用长远的眼光来看,刚开始使用 ESint 的时候可能会有很多问题,改起来也很费时费力,只要坚持下去,代码质量和开发效率都会得到提升,前期的付出都是值得的。

这些工具并不是必须的,没有它们你同样可以可以完成功能开发,但是利用好这些工具,你可以写出更高质量的代码。特别是一些刚刚接触的人,可能会觉得麻烦而放弃使用这些工具,失去了一次提升编程能力的好机会。

断点调试

截屏2021-08-26 下午2.04.51.png 点击创建 launch.json 文件

会在根目录下生成.vscode/launch.json

下面主要介绍两种调试方式:

  1. attach
"scripts": {
    ...
    "debug": "node --inspect-brk ./src/app.js",
    ...
}

执行 yarn debug

image.png

Chrome devtools

21ea3d991ab5663bc46fea8539b41d85.jpg 把刚才的端口 9229 填上去:

52506c311ee1553f2ad3b355c11bbd5c.jpg 然后就可以看到 chrome 扫描到了这个 target,点击 inspect 就可以连上这个 debugger server。

02b884c108efd7f57fe85136932039e0.jpg

dca51f82786e60d9865ab6198ba48140.jpg

attach

截屏2021-08-27 下午2.24.45.png

{
  "name": "Attach",
  "port": 9229, // 替换成 inspect-brk 服务的端口
  "request": "attach",
  "skipFiles": [
    "<node_internals>/**"
  ],
  "type": "pwa-node"
},

截屏2021-08-27 下午2.30.23.png

launch

这样通过node --inspect-brk 启动 debugger server,然后再添加vscode debug配置来连接上太麻烦了,能不能把这两步合并呢

办法是有的,只需要添加一个launch 的配置:

截屏2021-08-27 下午2.58.52.png

截屏2021-08-27 下午3.00.37.png

program 可以修改调试的路径,还可以设置 stopOnEntry 来在首行断住

更多调试细节可参考:mp.weixin.qq.com/s/-Hz4SkAp9…

到这里,我们的基础配置就完成了,下开始正式的编码

创建一个 http server

const http = require('http');

const hostname = '127.0.0.1';
const port = 3000;

const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello, World!\n');
});

server.listen(port, hostname, () => {
  console.log(`Server running at http://${hostname}:${port}/`);
});

一个 hello world 就完成了

当然,这个只是个demo,要实现我们的目标,还需要对 reqres做处理,req相当于就是请求相关的参数,res就是响应相关的参数

统一封装一个方法来处理

route(req, res, filePath, config)
const fs = require('fs')
// 异步处理
const { promisify } = require('util')
// 模板引擎
const handlebars = require('handlebars')
// 分段读取文件流
const range = require('./range')
// mimeType
const mime = require('./mime')
const path = require('path')

// 异步处理
const stat = promisify(fs.stat)
const readdir = promisify(fs.readdir)
// 拼接模板路径
const tplPath = path.join(__dirname, '../template/dir.tpl')
// 模板文件流
const source = fs.readFileSync(tplPath)
// handlebars引擎处理
const template = handlebars.compile(source.toString())

// req 请求体 res 响应内容 filePath 当前文件的绝对路径, config 为了取一个 root 路径
module.exports = async function (req, res, filePath, config) {
  try {
    // 异步读取文件
    const stats = await stat(filePath)
    // 判断是否是文件
    if (stats.isFile()) {
      // 动态读取当前文件的扩展名并获取相应的 mimeType
      const contentType = mime(filePath)
      // 设置响应头
      res.setHeader('Content-Type', contentType)
      // 读取文件流
      let rs
      const { code, start, end } = range(stats.size, req, res)
      if (code === 200) {
        res.statusCode = 200
        rs = fs.createReadStream(filePath)
      } else {
        res.statusCode = 206
        rs = fs.createReadStream(filePath, { start, end })
      }
      rs.pipe(res)
    } else if (stats.isDirectory()) {
      // 读取文件夹下的文件列表
      const files = await readdir(filePath)
      res.statusCode = 200
      res.setHeader('Content-Type', 'text/html')
      // 关键: path.relative(from, to),可以得相对地址
      // 
      const dir = path.relative(config.root, filePath)
      // 传给模板文件的数据
      const data = {
        title: path.basename(filePath),
        dir: dir ? `/${dir}` : '',
        files: files.map((file) => {
          return {
            file,
            icon: mime(file)
          }
        })
      }
      res.end(template(data))
    }
  } catch (error) {
    res.statusCode = 404
    res.setHeader('Content-Type', 'text/plain')
    res.end(`${filePath} is not a diretory or file \n ${error.toString()}`)
    console.error(error)
  }
}

1、异步处理 promisify

const stat = promisify(fs.stat) 
const readdir = promisify(fs.readdir)

const stats = await stat(filePath)

2、模板引擎

const handlebars = require('handlebars')

// 拼接模板路径 
const tplPath = path.join(__dirname, '../template/dir.tpl') 
// 模板文件流 
const source = fs.readFileSync(tplPath) 
// handlebars引擎处理 
const template = handlebars.compile(source.toString())

// 传给模板文件的数据 
const data = { 
    title: path.basename(filePath), 
    dir: dir ? `/${dir}` : '', 
    files: files.map((file) => { return { file, icon: mime(file) } }) 
} 
res.end(template(data))

3、响应头Content-Type动态变化 在服务器响应资源到前端时,会指定一个Content-Type,浏览器用对应的格式去解析渲染内容

原理:let ext = path.extname(filePath).split('.').pop().toLowerCase(),path.extname获取扩展名,在映射一个mimeType的值给Content-Type

4、range分段读取资源

module.exports = (totalSize, req, res) => {
  const { range } = req.headers
  if (!range) {
    return {
      code: 200
    }
  }
  const sizes = range.match(/bytes=(\d*)-(\d*)/)
  const end = sizes[2] || totalSize - 1
  const start = sizes[1] || totalSize - end
  if (start > end || start < 0 || end > totalSize) {
    return {
      code: 200
    }
  }

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

  return {
    code: 206,
    start: global.parseInt(start),
    end: global.parseInt(end)
  }
}
let rs
const { code, start, end } = range(stats.size, req, res)
if (code === 200) {
    res.statusCode = 200
    rs = fs.createReadStream(filePath)
} else {
    res.statusCode = 206
    rs = fs.createReadStream(filePath, { start, end })
}

5、资源压缩(主要针对两种形式:gizp | deflate)

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

module.exports = (rs, req, res) => {
  const acceptEncoding = req.headers['accept-encoding']
  if (!acceptEncoding || !acceptEncoding.match(/\b(gizp|deflate)\b/)) {
    return rs
  }
  if (acceptEncoding.match(/\bgizp\b/)) {
    res.setHeader('Content-Encoding', 'gzip')
    return rs.pipe(createGzip())
  }
  if (acceptEncoding.match(/\bdeflate\b/)) {
    res.setHeader('Content-Encoding', 'deflate')
    return rs.pipe(createDeflate())
  }
  return false
}
rs = compress(rs, req, res)

6、缓存

const { cache } = require('../config/defaultConfig')

function refreshRes(stats, res) {
  const { maxAge, expires, cacheControl, lastModified } = cache
  if (expires) {
    res.setHeader('Expires', new Date(Date.now() + maxAge * 1000).toUTCString())
  }
  if (cacheControl) {
    res.setHeader('Cache-Control', `public, max-age=${maxAge}`)
  }
  if (lastModified) {
    res.setHeader('Last-Modified', stats.mtime.toUTCString())
  }
  // if (etag) {
  //   res.setHeader('Etag', `${stats.size}-${stats.mtime}`)
  // }
}
module.exports = function isFresh(stats, req, res) {
  refreshRes(stats, res)

  const lastModified = req.headers['if-modified-since']
  const etag = req.headers['if-none-match']

  if (!lastModified && !etag) {
    return false
  }
  if (lastModified && lastModified !== res.getHeader('Last-Modified')) {
    return false
  }
  // if (etag && etag !== res.getHeader('Etag')) {
  //   return false
  // }
  return true
}

chmod

当然,在未发布npm时,我们怎么在本地测试这个 bin/index 是否OK,可以执行 bin/index -p 端口号 如果报错,查看一下 bin/index 是否可以被执行(mac为例)

截屏2021-08-31 下午5.00.55.png

截屏2021-08-31 下午4.41.59.png

-rwxr-xr-x

第一位代表:d(文件夹) -(文件)

rwx 对应 读(read) 写(write) 执行(execute) 权限

了解更多关于chmod: www.runoob.com/linux/linux…

发布npm

根目录下手新建bin/dserver

#! /usr/bin/env node

require('../src/index')

package.json

"bin": {
    "dserver": "bin/dserver" // "dserver"这个key就是使用这个npm包时的命令 例 dserver -p 9999
},

由于app.js会自动,所以需要封装一下,class类 封装一下app.js

启动项目时的参数处理yargs,具体使用参照如下,更多详细文档参照:www.npmjs.com/package/yar…

const yargs = require('yargs')
const Server = require('./app')

const { argv } = yargs
  .usage('anywhere [options]')
  .option('p', { alias: 'port', describe: '端口号', default: 9527 })
  .option('h', {
    alias: 'hostname',
    describe: 'host',
    default: '127.0.0.1'
  })
  .option('d', {
    alias: 'root',
    describe: 'root path',
    default: process.cwd()
  })
  .version()
  .alias('v', 'version')
  .help()

console.log(argv)
const server = new Server(argv)
server.start()

配置好后,就可以发布npm,执行npm publish就OK了

如果发布不成功的情况,可以参照下面的可能性逐一排查
当你遇到困难时不要灰心,按照这个自检清单逐一排查: 
1.检查是否登录了npm (npm whoami) 
2.你是否有多个账号记混了,退出重新登录 
3.你注册号时所填的邮箱是否被验证了,如果没有刷新页面点击顶部通知栏重发验证邮件 
4.检查你的包名是否已被占用 
5.检查你的版本号是否是用过的版本号 
6.检查你的npm源是否是npm官方源(npm config list)

写在最后

Github: github.com/Spring-List…