【学习笔记】nodejs 对模块进行热更新

nodejs 可以使用两种方式加载模块,分别是 CommonJS(cjs)ECMAScript(esm) 模块。
本文将分别介绍如何通过清除缓存进行热更新

CommonJS 模块热更新

CommonJS 使用 require 导入模块,使用 module.exports 导出模块。

默认情况:

模块只有在第一次导入时才会加载文件,再次导入只能获取到之前的缓存
例如:

index.js
require("./log.js"); // 控制台打印信息require("./log.js"); // 没有打印信息
12
log.js
console.log("导入模块");
1

可以看到,第二次导入没有重新加载模块,而是直接读取缓存

删除缓存:

CommonJS 使用 require.cache 保存缓存,所以只需要删除对应模块的缓存就能重新加载。

const path = require("path"); require("./log.js"); // 控制台打印信息delete require.cache[path.resolve("./log.js")]; require("./log.js"); // 再次打印信息
1234

注意:缓存使用绝对路径

ECMAScript 模块热更新

ECMAScript 使用 import 导入模块,使用 export 导出模块。
要让 nodejs 启用 esm 模块加载器,需要在 package.json 加上 "type": "module"

默认情况:

模块只有在第一次导入时才会加载文件,再次导入只能获取到之前的缓存
例如:

index.js
import "./log.js"; // 控制台打印信息await import("./log.js"); // 没有打印信息
12
log.js
console.log("导入模块");
1

删除缓存:

esm 模块的缓存默认不暴露出来,需要在启动 node 时,使用 --expose-internals 标志。
注意:请使用最新版本node

参考信息:#49442

import path from "node:path"; import url from "node:url"; import module from "internal/process/esm_loader"; import "./log.js"; // 控制台打印信息const file = url.pathToFileURL(path.resolve("./log.js")).href; module.esmLoader.loadCache.delete(file); await import("./log.js"); // 再次打印信息
1234567

注意:缓存使用 file:/// 路径

实际应用

在开发 nodejs 应用的时候,为了方便可能会使用 nodemon 监听文件改变,然后重启应用。
可是这样每次修改文件都是完全重启,效率实在太低。
所以使用模块热更新可以更精准的达到马上生效的效果。

CommonJS 热更新接口

服务器接口为 koa,通过 chokidar 监听文件改变,然后热更新接口。

utils.js
const chokidar = require("chokidar");const path = require("path");const tree = new Map();const loadCache = require.cache;exports.watch = function watch(dir, callback) {  const cwd = path.resolve(dir);  const root = path.resolve(cwd, "index.js");  chokidar    .watch(["**/*.js"], { cwd, ignoreInitial: true })    .on("all", (_type, filename) => {      delCache(path.resolve(cwd, filename));      try {        callback();      } catch {}      buildTree(root);    });  buildTree(root);};function buildTree(path) {  tree.set(path, [path]);  const module = loadCache[path];  if (module) {    (function read(current) {      for (const child of current.children) {        if (!/node_modules/.test(child.filename)) {          let parents = tree.get(child.filename);          if (!parents) tree.set(child.filename, (parents = []));          parents.push(current.filename);          if (child.children) read(child);        }      }    })(module);  }}function delCache(path) {  const parents = tree.get(path);  if (parents) {    tree.delete(path);    delete loadCache[path];    console.log("删除缓存", path);    if (parents[0] != path) {      for (const path of parents) {        delCache(path);      }    }  }}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849

监听 api 文件夹,每次修改都会重新导入所有 api 接口:

index.js
const http = require("http");const { watch } = require("./utils.js");let api = require("./api/index.js");http  .createServer((req, res) => {    api(req, res);  })  .listen(80);watch("./api", () => {  api = require("./api/index.js");});
12345678910111213

在 api 文件夹下面包含多个路由文件:

├─index.js├─package.json├─utils.js├─api|  ├─a.js|  ├─b.js|  ├─index.js|  └─router.js
api/index.js
const Koa = require("koa");const Router = require("koa-router");const app = new Koa();const desktop = new Router();const router = require("./router.js");// app.use(...middleware);for (const key in router) {  desktop.use("/" + key, router[key].routes(), router[key].allowedMethods());}app.use(desktop.routes());app.use(desktop.allowedMethods());module.exports = app.callback();
12345678910111213141516

路由文件:

api/router.js
exports.a = require("./a.js");exports.b = require("./b.js");
12
api/a.js
const Router = require("koa-router");const router = new Router();router.get("/", (ctx) => {  ctx.body = "a.js";});module.exports = router;
12345678
api/b.js
const Router = require("koa-router");const router = new Router();router.get("/", (ctx) => {  ctx.body = "b.js";});module.exports = router;
12345678

修改 api 文件夹下面的文件,就会让 index.jsapi 变量更新。
从而达到了热更新的目的。

ECMAScript 热更新接口

esm 模块的缓存默认不暴露出来,需要在启动 node 时,使用 --expose-internals 标志。
注意:请使用最新版本node,需要在 package.json 加上 "type": "module"
跟 CommonJS 的差异:
require.cache 相当于 esmLoader.loadCache,类型从 Object 变成 Map
module.filename 相当于 module.url,id 从 绝对路径 变成 file:/// 路径
module.children 相当于 module.linked,类型从 Array 变成 Promise<ArrayLike>

utils.js
import chokidar from "chokidar";import path from "node:path";import url from "node:url";import module from "internal/process/esm_loader";const tree = new Map();const loadCache = module.esmLoader.loadCache;export function watch(dir, callback) {  const cwd = path.resolve(dir);  const root = url.pathToFileURL(path.resolve(cwd, "index.js")).href;  chokidar    .watch(["**/*.js"], { cwd, ignoreInitial: true })    .on("all", async (_type, filename) => {      delCache(url.pathToFileURL(path.resolve(cwd, filename)).href);      try {        await callback();      } catch {}      buildTree(root);    });  buildTree(root);}function buildTree(path) {  tree.set(path, [path]);  const module = loadCache.get(path);  if (module) {    (async function read(current) {      const children = await current.linked;      for (let i = 0; i < children.length; i++) {        if (!/node_modules/.test(children[i].url)) {          let parents = tree.get(children[i].url);          if (!parents) tree.set(children[i].url, (parents = []));          parents.push(current.url);          read(children[i]);        }      }    })(module);  }}function delCache(path) {  const parents = tree.get(path);  if (parents) {    tree.delete(path);    loadCache.delete(path);    console.log("删除缓存", path);    if (parents[0] != path) {      for (const path of parents) {        delCache(path);      }    }  }}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152

监听 api 文件夹,每次修改都会重新导入所有 api 接口:

index.js
import http from "node:http";import { watch } from "./utils.js";import { app } from "./api/index.js";let api = app.callback();http  .createServer((req, res) => {    api(req, res);  })  .listen(80);watch("./api", async () => {  const { app } = await import("./api/index.js");  api = app.callback();});
123456789101112131415

在 api 文件夹下面包含多个路由文件:

├─index.js├─package.json├─utils.js├─api|  ├─a.js|  ├─b.js|  ├─index.js|  └─router.js
api/index.js
import Koa from "koa";import Router from "koa-router";import * as router from "./router.js";export const app = new Koa();const desktop = new Router();// app.use(...middleware);for (const key in router) {  desktop.use("/" + key, router[key].routes(), router[key].allowedMethods());}app.use(desktop.routes());app.use(desktop.allowedMethods());
1234567891011121314

路由文件:

api/router.js
export * from "./a.js";export * from "./b.js";
12
api/a.js
import Router from "koa-router";export const a = new Router();a.get("/", (ctx) => {  ctx.body = "a.js";});
123456
api/b.js
import Router from "koa-router";export const b = new Router();b.get("/", (ctx) => {  ctx.body = "b.js";});
123456

修改 api 文件夹下面的文件,就会让 index.jsapi 变量更新。
从而达到了热更新的目的。

参考:
Hot Module Replacement for Node.js
Invalidate cache when using import #49442