挑战21天手写前端框架 day7 使用 Socket 实现 esbuild 的热加载服务 hmr

简介: 挑战21天手写前端框架 day7 使用 Socket 实现 esbuild 的热加载服务 hmr

image.png

阅读本文需要 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);
复制代码

官网 ws 用法



与 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=nodeoutdir

"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 保存,页面会自动刷新就说明成功了,如果对你来说这些功能添加不是很熟悉的话,你可以阅读下面的源码归档。多看几遍就能明白了。


源码归档

目录
相关文章
|
前端开发 JavaScript CDN
推荐一款稳定快速免费的前端开源项目 CDN 加速服务
推荐一款稳定快速免费的前端开源项目 CDN 加速服务
333 0
|
23天前
|
存储 前端开发 JavaScript
前端的全栈之路Meteor篇(四):RPC方法注册及调用-更轻量的服务接口提供方式
RPC机制通过前后端的`callAsync`方法实现了高效的数据交互。后端通过`Meteor.methods()`注册方法,支持异步操作;前端使用`callAsync`调用后端方法,代码更简洁、易读。本文详细介绍了Methods注册机制、异步支持及最佳实践。
|
3月前
|
前端开发 JavaScript NoSQL
构建苏宁商品详情页:从前端展示到后端服务的实战指南
苏宁商品详情页集成前端展示与后端服务,前端利用HTML/CSS/JavaScript呈现信息,后端采用Node.js/Java/Python等技术处理请求并从MySQL/MongoDB等数据库获取数据。示例中,Node.js通过Express框架搭建API,模拟商品查询逻辑。实际应用更为复杂,涵盖用户评价、推荐等功能,并需考虑分布式架构、安全防护及性能优化等方面。
构建苏宁商品详情页:从前端展示到后端服务的实战指南
|
4月前
|
监控 前端开发 JavaScript
|
4月前
|
前端开发 JavaScript
前端框架与库 - Angular基础:组件、模板、服务
【7月更文挑战第16天】Angular,谷歌维护的前端框架,专注构建动态Web应用。组件是核心,包含行为逻辑的类、定义视图的模板和样式。模板语法含插值、属性和事件绑定。服务提供业务逻辑,依赖注入实现共享。常见问题涉及组件通信、性能和服务注入。优化通信、性能并正确管理服务范围,能提升应用效率和质量。学习组件、模板和服务基础,打造高效Angular应用。
65 1
|
5月前
|
监控 前端开发 JavaScript
|
5月前
|
移动开发 Java API
Java Socket编程 - 简单的问候服务实现
Java Socket编程 - 简单的问候服务实现
28 0
|
前端开发
前端学习笔记202305学习笔记第二十九天-Socket.io文本编辑实时共享之socket发送文本状态2
前端学习笔记202305学习笔记第二十九天-Socket.io文本编辑实时共享之socket发送文本状态2
54 0
|
前端开发
前端学习笔记202305学习笔记第二十九天-Socket.io文本编辑实时共享之自定义指令创建之2
前端学习笔记202305学习笔记第二十九天-Socket.io文本编辑实时共享之自定义指令创建之2
50 0
|
6月前
|
缓存 JavaScript 前端开发
GitLab 官网使用 pages 服务,发布 vue 前端项目
GitLab 官网使用 pages 服务,发布 vue 前端项目
206 1