如何实现一个简易devServer
前言
在项目开发中,我们一般通过 npm(yarn,pnpm ...) run dev 可以启动我们的项目,然后再通过控制台生成的的 http(s)://localhost:(port) 的链接就可以对本地项目进行访问和查看,那我们自己如何去设计开发一个这样的本地服务去运行我们的项目呢?
问题分析
刚刚我们说了,http(s)://localhost:(port) 这样一个链接本质上来说是一个本地服务,那我们现在要解决的问题主要有几个:
- 如何启动一个本地服务
- 如何在这个服务上处理和成功展示我们的项目文件代码
- 如何在我们修改代码时进行 HMR(热更新)
社区现状
现在我们常用的构建工具都自带 devServer,包括 vite,webpack 等,大家也可以去看看它们的源码,我们这里的实现也参考了 vite 源码的中的部分代码,其中 picocolors, connect, chokidat, ws 等npm包的使用都是参考了vite中的运用
实现思路
如何启动一个本地服务
这个问题其实很简单,这里我们可以直接使用node自带的的http模块,然后直接创建一个服务即可,以下代码就可以创建一个最基础的服务
import http from "http"
const PORT = 8888
// 这里的PORT就是我们启动服务所在的端口
http.createServer().listen(PORT)
然后我们把我们写好的中间件插入其中就可以在这个服务的基础上实现其他功能了,这里的connect是一个npm包,具体功能可以去官网了解
// connect is a simple framework to glue together various "middleware" to handle requests.
const app = connect()
// 这里的A,B指代某个已经实现好的中间件
app.use(A)
app.use(B)
http.createServer(app).listen(PORT)
美化一下控制台,使用picocolors在控制台输出有颜色的提示
import color from "picocolors"
http.createServer(app).listen(PORT)
// ...
// 提示服务启动成功
console.log(
`${color.red('z-dev-server')} 服务启动完成! ${color.green(
`http://localhost:${PORT}`
)}`
)
效果如下:
是不是感觉漂亮一些了~
如何在服务上处理和展示项目文件
服务起来之后,我们就可以通过服务去展示本地文件了,这个功能就需要通过写中间件来实现
展示项目文件
其实解决这个问题的核心思路就是去根据我们客户端请求的路径,去读取对应的文件,然后在服务器上展示,那么我们的代码大致思路应该是这样:
// 中间件
const handleFileShow = (req, res) => {
// 根据请求路径读取文件路径和文件类型
const { filePath, fileType } = getPathAndType(req.url)
try {
// 根据路径读取文件内容(如果不配置utf8,就会是二进制格式的内容)
let file = fs.readFileSync(filePath,'utf8')
// 根据文件类型设置Content-Type,使得浏览器识别对应文件类型
res.writeHead(200, { "Content-Type": fileType })
// 将文件内容交给浏览器
res.end(file)
} catch {
// 报错页面的处理
res.writeHead(500, { "Content-Type": "text/plain" })
res.end("You have to create an index.html")
}
}
所以通过这个中间件,我们实现了一个这样的流程
用户请求路径->服务端读取文件内容->浏览器展示内容
举个🌰:http://localhost:8888/ -> 路径为/,读取本地index.html文件-> 浏览器展示index.html内容
解决npm包引入问题
但是现在有一个问题需要解决:比如说我们在index.html中引入了一个main.js文件,然后main.js文件中引入了lodash-es/cloneDeep.js来使用,这个时候会报错如下:
为什么会报错呢?
因为我们在代码中的引入是直接通过 import has from 'lodash-es/has.js' 引入的,而你在浏览器中运行是找不到这个文件的,我们的依赖都在本地的node_modules里面,如果我们直接通过 import cloneDeep from './node_modules/lodash-es/cloneDeep.js' 就可以成功
但是我们总不能让开发者引入所有依赖都带上node_modules吧,所以这个事情应该通过devServer来做,这里我们也可以通过一个中间件来实现:
// 中间件
const replaceImport = (req, res, next) => {
const { url } = req
// 如果需要替换import内容
if (needReplaceImport(url)) {
// 根据路径读取文件内容
const { filePath, fileType } = getPathAndType(url)
let file = fs.readFileSync(filePath,'utf8')
// 正则匹配import from导入的内容
const regex = /import\s+[\w\s{},*]*\s+from\s+['"](?!\.\/)([^'"]+)['"]/g;
// 替换代码中的导入路径
file = file.replace(regex, (_match, capture) => {
// 通过getEntry方法去找到模块对应的入口
const entry = getEntry(capture)
// 将入口前面加上node_modules
return `from "${path.join(getRelativePath("node_modules"), entry)}"`
})
res.writeHead(200, { "Content-Type": fileType })
res.end(file)
// 这个return时必须的,可以直接阻断满足条件的请求继续往下走
return;
}
// 不需要替换直接next
next()
}
经过这个中间件处理之后,我们的 js 文件中的导入代码都会被 devServer 替换成上述路径,并且能够成功方法,而用户不需要去感知这个事情
如何实现HMR
个人认为 HMR 是 devServer 中比较重要的一个功能,可以让用户在修改文件之后就立马看到更新之后的效果,我们这里可以使用 WebSocket(后文用ws简称) 实现:
具体思路
- 服务端在开启基础服务的同时开启一个ws服务
- 在读取首个文件(比如index.html)时,向其中注入一串代码,这串代码的功能就是在客户端去连接服务端对应端口的ws服务
- 服务端的ws服务去监听本地文件变化,当文件变化时给客户端的ws发送change消息
- 客户端的ws接收到change消息之后reload页面或者更新资源
实现
服务端在开启基础服务的同时开启一个ws服务
// ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and server implementation.
import { WebSocketServer } from "ws"
// chokidar is a Minimal and efficient cross-platform file watching library
import chokidar from "chokidar"
// 创建webSocker服务方法
function createWSServer{
// 在9999端口创建一个ws服务
const server = new WebSocketServer({ port: 9999 })
// 如果ws成功连接就走下面的毁掉
server.on("connection", (ws) => {
// chokidar.watcher监听文件变化,WATCH_LIST代表你需要监听的目录
const watcher = chokidar.watch(WATCH_LIST)
// 文件变化之后告诉客户端,并且传递需要的数据
watcher.on("change", (file) => {
ws.send(JSON.stringify({type: "change"})
)
})
})
}
function createServer() {
// 创建devServer
http.createServer(app).listen(PORT)
// 创建webSocket服务
createWSServer()
console.log(
`${color.red(PROJECT_NAME)} 服务启动完成! ${color.green(
`http://localhost:${PORT}`
)}`
)
}
在处理文件时向客户端注入ws代码,也就是上文的handleFileShow中间件中加上一段逻辑
const handleFileShow = (req, res) => {
// 根据请求路径读取文件路径和文件类型
const { filePath, fileType } = getPathAndType(req.url)
try {
// ...
// 如果是入口文件(这里是index.html)
if (path.basename(filePath) === "index.html") {
// 截取index.html中的head内容
const regex = /(<head>)([\s\S]*?<\/head>)/i
const match = file.match(regex)
// 要被注入的script标签内容(引入/src/client/index.js的代码)
const clientScript = "<script src='zDevServer/src/client/index.js'></script>"
if (match) {
// 往html中的head之间插入上面的标签代码
file = file.replace(match[0], match[1] + clientScript + match[2])
}
}
} catch {
// 报错页面的处理
// ...
}
}
src/client/index.js的代码如下
// 链接服务端对应端口的ws服务
const ws = new WebSocket("ws://localhost:9999")
ws.addEventListener("open", ({ target: socket }) => {
socket.addEventListener("message", ({ data }) => {
// 获取服务端ws传来的数据消息
const result = JSON.parse(data)
switch (result.type) {
// 接受到服务端文件改变的消息
case "change":
// 如果文件改变就之间reload页面
window.location.reload()
break
}
})
})
这样一个简易的hmr就实现好了
还可以做的优化
这个demo毕竟是一个简易版,所以还有很多可以优化的点,这里指出两个
prebundling
因为我们在实现import替换的时候只是单纯的将模块引入的路径增加了一个node_modules来使得项目可以正常使用依赖,这样其实看起来就不太好,这里给一个思路: 就是在使用这些依赖之前,直接对其进行prebundling,也就是在使用某个依赖之前将其打包放到一个指定的文件夹内,然后读取的时候直接读取该文件夹
重复端口的校验
现在这个devServer当没有关闭原终端再去启动时会报错,这里推荐的思路是检测是否有重复端口,如果有的话就端口+1,也是现在主流devServer的做法
总结
本文主要通过node服务实现了一个简易的devServer,其中涉及了一些node服务端,文件读取,websocket等相关知识,希望能对大家有一些帮助
转载自:https://juejin.cn/post/7318083996709044233