阅读本文需要 20 分钟,编写本文耗时 5 小时。esbuild 生态不太完善,很多资料需要翻阅 Issues 甚至阅读源码,有一些实现需要反复的尝试。
上一次我们遗留了两个问题。
1、服务端口被占用
2、每次修改项目都需要刷新页面
自动检测端口可用性
第一个问题比较简单,端口被占用或者端口不可用,那就自动找一个端口用呗。
我用过两个包,第一个是用 umi@1 中用到的 detect-port
,一个是 umi@4 中用到的 portfinder
。
这里我们随便用 portfinder
找一个可用的端口吧。
import portfinder from 'portfinder'; import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); const port = await portfinder.getPortPromise({ port: DEFAULT_PORT, }); app.listen(port, ()=>{}); 复制代码
热更新 hmr 原理
因为我们的整个构建流程都没有使用到 webpack ,所以没办法使用 webpack 的 hmr 能力,要完全实现 hmr 和 react 的快速刷新功能还是挺复杂的。后面看看有机会的话我们再来完善它。现在我们只是简单的实现我们的需求,“项目文件被修改之后,自动刷新页面”。
首先我们来分析一下 webpack 的 hmr 原理。
1、项目页面(以下称之为客户端)下载 manifest 资源文件,你可以理解为需要加载的链接的清单列表
2、客户端加载文件完成之后与 webpack 的开发服务器(以下称之为服务端),建立 Socket 通信
3、webpack 监听文件变化,产生增量构建,并向客户端发送构建事件
4、客户端接收到构建事件之后,向服务端请求 manifest 资源文件,比对文件变化,确认去要增量下载的文件
5、客户端加载增量构建的模块
6、webpack runtime 出发热更新回调,执行变更逻辑。
如果你使用 webpack ,经常会在修改项目文件之后,发现浏览器发起了一个带有 hot
字样的链接,这个请求链接就是这么来的。
因为 esbuild 没有办法做增量构建,所以我们结合上面的原理,完成我们的逻辑。
1、项目加载完成,注入 Socket 客户端脚本
2、与服务端建立 Socket 通信通道
3、esbuild 监听事件变化,执行 onRebuild 事件
4、向客户端发送 reload
事件
5、客户端执行 window.location.reload()
刷新页面
实现 Socket 服务端
安装 ws
模块
pnpm i ws 复制代码
使用 ws
新建一个 WebSocketServer
import { WebSocketServer } from 'ws'; import { createServer } from 'http'; const server = createServer(); const wss = new WebSocketServer({ noServer: true, }); server.on('upgrade', function upgrade(request, socket, head) { wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request); }); }); server.listen(8080); 复制代码
与 express 结合使用
由于我们之前已经使用了 express 建立了我们的服务,所以结合上述需求,我们可以使用 http.createServer
来新建我们的 express 服务。
import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); app.listen(DEFAULT_PORT, ()=>{}); 复制代码
改为
import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import express from 'express'; import { DEFAULT_PORT } from './constants'; const app = express(); const server = createServer(app); const wss = new WebSocketServer({ noServer: true, }); server.on('upgrade', function upgrade(request, socket, head) { wss.handleUpgrade(request, socket, head, function done(ws) { wss.emit('connection', ws, request); }); }); server.listen(DEFAULT_PORT,()=>{}); 复制代码
这样使用,我们还能保留 express 的中间件功能,可能是我写起来比较顺手吧。
实现 Socket 客户端
先给一段一眼就能看懂的代码吧,就是使用 window.WebSocket
链接我们的 Socket 服务端,在监听事件类型为 reload
时,执行刷新页面。
if ('WebSocket' in window) { const socket = new window.WebSocket('ws://127.0.0.1:8888'); socket.onmessage = function (msg) { const data = JSON.parse(msg); if (data.type === 'reload') window.location.reload(); }; } 复制代码
Socket 保活
给 Socket 增加心跳包(我不确定这个形容是否正确,实习做游戏的时候,带我的坛爷是这么讲的),就是定时给 Socket 服务端发送一个信息,告诉他你还“活着”。
socket.onmessage = function (msg) { const data = JSON.parse(msg); if (data.type === 'connected') { console.log(`[malita] connected.`); // 心跳包 pingTimer = setInterval(() => socket.send('ping'), 30000); } if (data.type === 'reload') window.location.reload(); }; 复制代码
增加断线重连逻辑
断线之后,先停止“心跳,因为链接已经中断了,你在一直发送信息,只会重复的报错。由于我们的服务还是一个 express 服务,所以我们可以写一个死循环,不断的向服务端发送请求,当服务端重启完成之后,只要刷新页面就可以重新连接 Socket,这个实现是从 vite 抄的,感觉实现很优雅,就拿到 umi@4 中使用了。
async function waitForSuccessfulPing(ms = 1000) { while (true) { try { await fetch(`/__malita_ping`); break; } catch (e) { await new Promise((resolve) => setTimeout(resolve, ms)); } } } const socket = new window.WebSocket('ws://127.0.0.1:8888'); socket.onclose = function (msg) { if (pingTimer) clearInterval(pingTimer); console.info('[malita] Dev server disconnected. Polling for restart...'); await waitForSuccessfulPing(); window.location.reload(); }; 复制代码
整理一下我们上面的逻辑,最终实现我们的 Socket 客户端代码如下:
function getSocketHost() { const url: any = location; const host = url.host; const isHttps = url.protocol === 'https:'; return `${isHttps ? 'wss' : 'ws'}://${host}`; } if ('WebSocket' in window) { const socket = new WebSocket(getSocketHost(), 'malita-hmr'); let pingTimer: NodeJS.Timer | null = null; socket.addEventListener('message', async ({ data }) => { data = JSON.parse(data); if (data.type === 'connected') { console.log(`[malita] connected.`); // 心跳包 pingTimer = setInterval(() => socket.send('ping'), 30000); } if (data.type === 'reload') window.location.reload(); }); async function waitForSuccessfulPing(ms = 1000) { while (true) { try { await fetch(`/__malita_ping`); break; } catch (e) { await new Promise((resolve) => setTimeout(resolve, ms)); } } } socket.addEventListener('close', async () => { if (pingTimer) clearInterval(pingTimer); console.info('[malita] Dev server disconnected. Polling for restart...'); await waitForSuccessfulPing(); location.reload(); }); } 复制代码
使用 esbuild 构建浏览器端代码
值得注意的是客户端代码,我们是在浏览器端使用的,而之前我们的 esbuild 构建项目的产物是 node 端使用的。所以我们要在 package.json
中增加一段构建客户端代码的脚本。(客户端代码放在 client/client.ts) 主要是修改了 --platform=node
和 outdir
。
"scripts": { "build": "pnpm esbuild ./src/** --bundle --outdir=lib --platform=node --external:esbuild", "build:client": "pnpm esbuild ./client/** --outdir=lib/client --bundle --external:esbuild", "dev": "pnpm build -- --watch" }, 复制代码
编写框架的妥协
现在是 2022 年 4 月 18 日 11 点,我查阅了很多资料,esbuild serve 不能响应 onRebuild, esbuild build 和 express 组合不能不写入文件,相关问题 Issues。
esbuild 的作者不太想支持 live serve,并且 esbuild serve 在执行插件时无法响应 onEnd
事件(bug),所以以下实现,做了妥协。如果你是在很久远的未来,看到这篇文章,那你可能可以直接使用 esbuild serve 来实现以下的功能。
只需在 onRebuild 或者 onEnd 事件中调用
sendMessage('reload')
即可。
使用 esbuild build watch 模式替代 esbuild serve
在监听服务启动的时候,构建我们的项目文件。将它们写入到 esbuildOutput
(这个变量会在下面说明)。build 的配置就是我们之前使用的 esbuild.serve(serveConfig,buildConfig)
时用到的 buildConfig
。只是增加了 watch
模式。
malitaServe.listen(port, async () => { console.log(`App listening at http://${DEFAULT_HOST}:${port}`); try { await build({ outdir: esbuildOutput, platform: DEFAULT_PLATFORM, bundle: true, watch: { onRebuild: (err, res) => { if (err) { console.error(JSON.stringify(err)); return; } sendMessage('reload') } }, // ... other config }); } catch (e) { console.log(e); process.exit(1); } 复制代码
编写静态资源服务
将 esbuild 的构建产物,放到静态资源服务中给客户端使用,这个使用 express 非常容易实现。
import { DEFAULT_OUTDIR } from './constants'; const esbuildOutput = path.resolve(cwd, DEFAULT_OUTDIR); app.use(`/${DEFAULT_OUTDIR}`, express.static(esbuildOutput)); 复制代码
这样当浏览器发起 /${DEFAULT_OUTDIR}
前缀的请求时,就会被重定向(或者称之为代理?)到 esbuildOutput
。 我们上面构建中提到将产物输出到了 esbuildOutput
中。 之后修改我们的 html 返回。
app.get('/', (_req, res) => { res.set('Content-Type', 'text/html'); res.send(`<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Malita</title> </head> <body> <div id="malita"> <span>loading...</span> </div> - <script src="http://${DEFAULT_HOST}:${DEFAULT_BUILD_PORT}/index.js"></script> + <script src="/${DEFAULT_OUTDIR}/index.js"></script> </body> </html>`); }); 复制代码
注入 Socket 客户端脚本
因为我们的客户端脚本是在框架中实现,并不在项目的文件中,因为我们可以用同样的静态资源服务器
的方法,将 client 文件返回给浏览器。其实在 esbuild 体系中,有一种更加“有趣”的实现,就是可以使用插件无中生有
,就是你 import 一个根本不存在的文件,然后通过插件中匹配你的引用路径,返回一个编译后的代码段,esbuild 插件开发我们会在后面的文章中体现,所以这里先不使用这种方式。
//__dirname 文件所在路径 cwd 命令执行路径 app.use(`/malita`, express.static(path.resolve(__dirname, 'client'))); 复制代码
然后在返回的 html 中添加引用
<script src="/malita/client.js"></script> 复制代码
感谢阅读,今天的文章需要一点点基础,如果你是完全零基础阅读这篇文章,那你可能需要反复阅读。你可以尝试着在上一次源码的基础上添加这些功能。只要实现了,修改项目文件 examples/app/src/index.tsx
保存,页面会自动刷新就说明成功了,如果对你来说这些功能添加不是很熟悉的话,你可以阅读下面的源码归档。多看几遍就能明白了。