首页 / 资讯中心 / 文章详情

零依赖极简工具caveman:用Node.js原生API搞定静态服务与Markdown渲染

零依赖极简工具caveman:用Node.js原生API搞定静态服务与Markdown渲染 ★ FEATURED ARTICLE
做这个项目的起因挺可笑。有一次我要给团队写一份内部工具的说明页内容是十几篇 Markdown 加几张截图。我本来想着“直接起个静态服务器不就行了”结果手滑打开了同事推荐的工程化脚手架一路默认配置点下去等回过神来项目目录里多了两万多行第三方依赖。那一刻我满脑子只有一个词荒唐。我最初只是想打开一个文件夹里的 HTML最后却被迫维护一个构建系统。这个项目caveman就是在这种情绪下诞生的——一个刻意保持零第三方依赖的极简开发工具集用 Node.js 原生 API 实现了本地静态服务、文件变更监听、Markdown 渲染三件事。它不是生产级框架也不该是它解决的是“我明明只想写个说明文档为什么还得装打包器”这种现代开发日常里的荒谬。适合看这篇文章的人我猜有三种被工具链绑架到喘不过气的开发者想搞懂 HTTP 服务、文件监听、解析器到底怎么运转的初学者以及那些写脚本时总被依赖体积和安装速度惹毛的人。下面我尽量把思路、代码、踩坑都摊开讲保证你可以照着抄。1. 项目思路被工具链绑架后我决定做个“原始人”1.1 核心需求三个功能解决“看文档、做原型、跑演示”先把 caveman 的定位说清楚它不是又一个重型脚手架而是一个“够用就好”的本机开发服务。整个项目只有三个核心功能对应我日常写作和原型验证里最高频的三类需求。第一个功能是本地静态资源服务。给我一个目录我访问目录里任何存在的文件它都能正确返回内容。第二个功能是文件变更监听与自动刷新。我保存 Markdown 或 CSS 后浏览器自动刷新不用手动切窗口按 F5。第三个功能是极简 Markdown 渲染。把.md文件渲染成可阅读的 HTML 页面省去我到处找预览插件。这三个功能背后有同一个判断我观察了自己写文档的习惯九成时间只需要“写一行看一眼效果”。为了这一眼效果去维护构建配置、热更新插件、路由系统本质上是在为 10% 的场景付 500% 的成本。有时候最原始的工具反而是最合身的。场景传统方案caveman 的做法本地预览 HTML/Markdown装 Live Server 或脚手架原生 http 模块一条命令启动保存后自动刷新热更新插件 WebSocket文件监听 SSE 推送Markdown 转 HTML引入 marked 语法高亮正则拆解自己写迷你渲染器1.2 选型逻辑为什么坚持零依赖我见过太多人把“引入依赖”当成解决问题的默认姿势。依赖不是免费的它的价格往往不在安装那一刻结算而是埋在每一次升级、每一条安全通告、每一个你不熟悉的 API 设计里。用吃饭来类比外卖确实快但你想吃碗清汤面的时候外卖得凑起送价自己煮泡面三分钟就好还不用等骑手。caveman 就是那碗泡面。有人会质疑你自己写静态服务器、自己写文件监听、自己写 Markdown 渲染这不是重复造轮子吗我承认从纯功能角度确实有现成的轮子。但从长期维护角度轮子的重量才是我真正在意的东西。这个项目跑了几个月node_modules目录根本不存在安装时间约等于零不用担心某个依赖被作者删库更不用为了一个补丁去升级整条依赖链。选择零依赖还有一层很实在的训练意义用原生 API 写服务会逼你把 HTTP 状态码、MIME 类型、路径解析这些基础概念重新学一遍。你会通过报错看清协议而不是在文档里猜协议。这个收获比工具本身值钱得多。2. 底层逻辑caveman 三个模块的设计拆解2.1 静态服务模块HTTP 服务不难难在不踩坑静态服务的核心就一句话把 URL 路径映射到文件系统路径然后读文件返回。但这里藏着两个大坑路径安全和编码处理。路径安全是必须放在第一位考虑的事。如果 URL 是/../../etc/passwd服务端直接把路径拼到根目录后面去读这就是一个严重的目录穿越漏洞。处理方式也不复杂先解码 URL 编码再用path.normalize规范化路径最后用path.relative判断最终的绝对路径是否还在根目录内。任何试图跳出根目录的请求一律返回 404。编码问题的坑更隐蔽。中文文件名在 URL 里通常会被编码成%E4%B8%AD%E6%96%87如果你忘了decodeURIComponent文件系统里根本找不到对应文件。但如果用户故意传一个畸形的编码串decodeURIComponent会直接抛异常必须 try/catch 兜住否则整个服务都会崩掉。搞定了安全与编码剩下的就是一张 MIME 类型映射表。.html返回text/html.css返回text/css.js返回text/javascript图片类各归各。MIME 写错会导致浏览器不渲染而是直接下载文件这一点在后面的踩坑环节我还会细说。2.2 文件监听模块fs.watch 与轮询的折中方案文件监听的关键难点是跨平台一致性。Node.js 的fs.watch在不同操作系统上行为差异很大macOS 和 Windows 上可以监听目录并递归获取子目录事件但 Linux 上很多发行版并不支持recursive: true。如果你写完代码只在 mac 上测发到 Linux 服务器上就会发现——静悄悄的什么都没触发。更麻烦的是编辑器对文件的操作方式。很多编辑器保存文件时并不是“修改原文件”而是“先写一个临时文件再原子替换”。对文件监听来说这意味着fs.watch监听的句柄可能因为文件被替换而失效。我最终选了一个更笨但更稳的方案轮询对比 防抖。具体做法是每隔一段时间扫描目标目录下所有文件对比每个文件的mtimeMs时间戳发现变化就触发回调。轮询间隔我设为 600ms配合 300ms 的防抖基本能覆盖所有手动保存场景实时性体感上接近“秒刷新”。代价是 CPU 占用略高但这个工具本来就是本机开发用的完全可接受。2.3 迷你 Markdown 渲染器解析器的边界感自己写 Markdown 渲染器最大的教训是要有边界感。市面上有 marked、markdown-it 这种成熟引擎我并不是要替代它们我的需求只是“把文档站点样式的 Markdown 渲染成能看的 HTML”。所以我实现了一个按顺序处理的迷你渲染器先把代码块提取出来占位避免后续行级正则误伤。处理标题#到######分别映射到h1到h6。处理无序列表把-开头的行包成li。处理行内加粗和斜体。最后处理段落。这个流程里有几个容易踩的细节。代码块必须最先处理并替换成占位符否则反引号里的#会被标题正则命中代码直接被改得面目全非。所有用户输入内容在拼进 HTML 之前必须做 HTML 转义否则你渲染一篇笔记的时候可能顺手执行了一段脚本——这就是 XSS。列表和段落的边界判断比我预想中麻烦我的做法是简化列表行不包段落其他非空行按段落处理。这套渲染器处理我自己写的文档完全够用。遇到复杂的 GFM 表格、嵌套引用、任务清单它会显示得不够完美但至少不会崩溃。这个取舍就是所谓的边界感。3. 实操记录从零到能用的完整实现过程3.1 初始化与目录结构环境只需要 Node.js 18 以上因为我会用到原生的fetch特性做 SSE 客户端测试后来发现没必要但版本高一点没坏处。初始化过程非常简单直接看目录结构caveman/ ├── bin/ │ └── caveman.js # 命令行入口 ├── lib/ │ ├── server.js # 静态服务模块 │ ├── watch.js # 文件监听模块 │ ├── render.js # Markdown 渲染模块 │ └── inject.js # 注入浏览器的自动刷新脚本 ├── test/ │ └── sample.md # 测试文档 └── package.json没有任何构建步骤没有任何配置文件。package.json里只有名字、版本和bin字段。我连npm init都懒得跑手写一个 JSON 就完事了。3.2 静态服务完整实现静态服务模块是整个项目的地基。核心代码如下// lib/server.js const http require(node:http); const fs require(node:fs); const path require(node:path); const MIME { .html: text/html; charsetutf-8, .css: text/css; charsetutf-8, .js: text/javascript; charsetutf-8, .md: text/markdown; charsetutf-8, .json: application/json; charsetutf-8, .png: image/png, .jpg: image/jpeg, .svg: image/svgxml, .ico: image/x-icon, }; function resolveSafePath(rootDir, urlPath) { let decoded; try { decoded decodeURIComponent(urlPath); } catch { return null; } const normalized path.normalize(decoded); const target path.join(rootDir, normalized); const relative path.relative(rootDir, target); if (relative.startsWith(..) || path.isAbsolute(relative)) { return null; } return target; } function createServer(rootDir, onReady) { const server http.createServer((req, res) { const urlPath req.url.split(?)[0]; if (urlPath /__caveman__/reload) { // SSE 长连接文件变更时推送刷新信号 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); req.on(close, () clients.delete(res)); clients.add(res); return; } const filePath resolveSafePath(rootDir, urlPath); if (!filePath) { res.writeHead(404); res.end(Not Found); return; } fs.stat(filePath, (err, stats) { if (err || !stats.isFile()) { res.writeHead(404); res.end(Not Found); return; } const ext path.extname(filePath).toLowerCase(); const contentType MIME[ext] || application/octet-stream; res.writeHead(200, { Content-Type: contentType }); fs.createReadStream(filePath).pipe(res); }); }); server.listen(0, () { const port server.address().port; if (onReady) onReady(port); }); return server; } const clients new Set(); module.exports { createServer, clients };注意这里server.listen(0)表示随机选一个可用端口避免和本机其他服务冲突。resolveSafePath里的相对路径判断是安全底线宁愿误伤正常请求也不能放开目录穿越的口子。3.3 文件监听驱动的浏览器自动刷新文件监听模块我用的是轮询方式。为了让跨平台行为一致也为了绕开编辑器原子写导致fs.watch失效的问题我选择定时扫描文件时间戳// lib/watch.js const fs require(node:fs); const path require(node:path); function walk(dir, results []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full path.join(dir, entry.name); if (entry.isDirectory()) { walk(full, results); } else { results.push(full); } } return results; } function watchFiles(rootDir, onChange, { interval 600, debounce 300 } {}) { const mtimeMap new Map(); let timer null; walk(rootDir).forEach((file) { mtimeMap.set(file, fs.statSync(file).mtimeMs); }); const check () { const changed []; walk(rootDir).forEach((file) { const stat fs.statSync(file); const last mtimeMap.get(file); if (last ! stat.mtimeMs) { mtimeMap.set(file, stat.mtimeMs); changed.push(file); } }); if (changed.length) { clearTimeout(timer); timer setTimeout(() onChange(changed), debounce); } }; setInterval(check, interval); return check; } module.exports { watchFiles };刷新信号用 SSE 推送到浏览器比 WebSocket 轻量太多。服务器端每发现文件变化就给所有连接的客户端写一条event: reload消息。浏览器端注入的脚本只有这么几句// lib/inject.js const script script const es new EventSource(/__caveman__/reload); es.addEventListener(reload, () location.reload()); /script; module.exports script;在返回 HTML 文件时我会把这脚本注入到/body之前。Markdown 渲染出的页面同样享受自动刷新待遇。3.4 命令入口与实测效果命令行入口是最后一块拼图。我需要一个干净的参数解析用原生process.argv就够了完全没必要引入 commander#!/usr/bin/env node // bin/caveman.js const path require(node:path); const { createServer, clients } require(../lib/server); const { watchFiles } require(../lib/watch); const { renderFile } require(../lib/render); const inject require(../lib/inject); const rootDir process.argv[2] || process.cwd(); // 访问 /index.md 时渲染成 HTML访问其他文件时返回原文这里有一个体验上的关键设计当浏览器访问.md文件时服务器不返回纯文本而是用渲染器转成 HTML同时注入自动刷新脚本。访问.html文件时也注入脚本。这样一来整个文档站的使用方式就是“写 Markdown保存看浏览器”三个动作反复循环。实测下来体验非常顺滑启动瞬间终端就打印出端口号没有任何安装等待保存文件后大约 1 秒内浏览器自动刷新内存占用低到可以忽略我同时开着文档站和编辑器完全没有感知。4. 踩坑实录运行中遇到的真问题和排查方法4.1 目录穿越与 URL 编码处理第一个坑是安全相关的。我最初写resolveSafePath时只做了path.join path.normalize没做decodeURIComponent。测试时发现一个问题访问/..%2f..%2fetc%2fpasswd这类经过编码的路径path.normalize根本识别不出..因为%2f还没被解码成/。浏览器地址栏会自动解码一部分但用 curl 或脚本请求时服务端收到的就是原始编码串。修复办法就是我上面代码里的顺序先解码再规范化再判断越界。这个顺序错了任何一步安全防护都是纸糊的。4.2 编辑器重复触发与原子写第二个坑是文件监听事件乱跳。用fs.watch时Vim 和 Sublime 这类编辑器保存文件时会先写临时文件再 rename 替换导致监听器要么收到多次 rename 事件要么因为原文件被替换而彻底失联。换成轮询后理论上事件不会丢但出现了另一个问题一次保存会多次刷新页面。原因是某些编辑器尤其是 Vim 的备份机制会同时改动.swp文件或备份文件轮询能抓到多个文件的变化再加上编辑器本身的额外写操作导致 600ms 内触发了两次变更。我的解法是在onChange外层加防抖所有变更事件集中起来等 300ms 内没有新事件了才真正触发刷新。实测下来刚保存完的瞬间就算有一连串文件写入也只会刷一次页面。4.3 MIME 类型与缓存问题第三个坑是浏览器把 HTML 当文件下载了。排查了半天结果是.md文件的 MIME 我写成了text/markdown而浏览器不支持渲染这种类型直接弹下载框。.html和.css的 MIME 如果漏掉charsetutf-8中文内容会变成乱码。这些都是小问题但每个都能让你对着屏幕怀疑人生十分钟。还有缓存问题改动 CSS 后浏览器可能还在用旧缓存样式不更新。我的解决方案是在注入脚本里不止监听 reload 事件而是每次刷新都加一个时间戳查询参数。对 HTML 文档本身则设置Cache-Control: no-cache保证每次刷新都拿到最新版本。4.4 现场排查思路速查表把常见问题整理成一张表方便你遇到问题的时候直接对号入座现象可能原因排查与解法访问中文文件名 404忘了decodeURIComponent确认解码后再拼文件路径输入/../etc/passwd能读到系统文件路径越界检查缺失解码→规范化→relative判断缺一不可保存文件后浏览器多次刷新编辑器原子写 多文件变更在回调外层加 300ms 防抖CSS 改了浏览器不变HTTP 缓存给文档设置no-cache或刷新时加时间戳参数.md文件变成下载MIME 类型不识别返回text/html而非text/markdownLinux 上fs.watch完全没有事件递归监听不受支持换成轮询 mtime 对比渲染后的 HTML 中代码块被解析掉了代码块处理顺序不对必须最先提取代码块并占位后再处理行级语法5. 影响范围caveman 适合哪些场景何时该果断放弃5.1 高性价比场景学习、写作、原型、教学我把 caveman 实际用过的场景列一下给想抄作业的人一个判断参考。场景一本地文档站。我现在的团队内部操作手册就是用 caveman 跑的。不依赖任何在线服务不需要 npm install拷贝到任何一台有 Node 的机器上就能起服务。这一点对写文档的人来说是巨大的解脱。场景二网络编程学习。如果你想理解 HTTP 协议或文件系统事件的机制直接读这个项目的源码比读十篇教程都管用。因为它短几百行代码里没有什么抽象跳转你能从req.url一路追到fs.createReadStream搞清楚一个请求从进入到返回的全部链路。场景三教学演示。我给新人讲前端构建工具时会用 caveman 做对比演示“没有构建工具也能开发”这件事。它破除了“不装依赖就没法干活”的迷信。场景四临时原型。有时候手头只有一份 HTML 或一个 JSON 文件需要临时看一眼caveman 就是最轻的那个服务器。5.2 决策边界什么时候回到成熟框架作为工具必须清楚自己的短板。caveman 完全没有类型检查、没有路由系统、没有浏览器兼容处理、没有构建优化它的 Markdown 渲染器也只是“能看”级别。一旦出现以下信号我就建议你果断换回成熟框架一多人协作时。当你的文档需要多人维护、审查需要精确控制样式布局时Mini 渲染器会成为限制到时候你就需要 VitePress 这类完整的文档框架了。二需要复杂交互时。如果页面里有动态路由、状态管理、组件化需求这不是静态服务加自动刷新能解决的请直接回到工程化轨道。三性能敏感时。轮询带来的 CPU 占用虽然低但在大型目录扫描时还是会拖慢磁盘 IO。生产环境的构建和热更新还是要交给有增量编译能力的工具。我的决策标准很简单如果你发现自己在控制“复杂度”上花的时间超过了“功能”本身就该考虑换工具了。反过来也一样如果你花在工程化配置上的时间比写内容还多那就该用 caveman 这类极简工具了。5.3 后续可以怎么扩展这个项目我后续还打算做几件小事。一是加一个--ext参数让人自定义监听的文件类型目前是所有文件都监听有些编辑器临时文件也会触发刷新。二是把渲染器里的行内代码和链接解析补上让常见文档语法能有更好的显示效果。三是做一个简单的目录索引页访问/时自动列出目录里的文件而不是 404方便快速跳转。对我个人而言做 caveman 最大的收获不是多了一个顺手的小工具而是重新体验了一次“从零写代码”的专注。现在主流开发方式已经被框架和工具链层层包裹我们习惯了把问题丢给抽象层却在问题真正出现的时候束手无策。偶尔回到原始状态亲手实现一遍底层逻辑反而能让人对整个系统有更踏实的掌控感。如果你也被依赖地狱折磨过我建议你也试试——挑一个最让你头疼的小工具不引第三方库从零写一个只属于自己口味的版本这个过程绝对比想象中有趣。
阅读完成 · 觉得有帮助?
咨询建站