是时候抛弃 chokidar 了?Node.js 原生支持 HMR 热更新!
大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
1. 为什么需要模块热加载
提高开发效率的关键因素之一是尽可能少地丢弃状态。
node --watch -r ts-node/register src/index.ts
这意味着在 Node.js 中新的 --watch 标志位用处并不大,因为其会丢弃所有状态。理想的做法是,当模块或其依赖的模块发生变更时,简单地使其失效。这样,所有 import 和数据 data 都能始终保持最新,但只有部分模块树会被重新执行。
import {FileTree, hooks} from "immaculata";
import {registerHooks} from "module";
// 在 “./src” 下保留文件树的内存版本
const tree = new FileTree("src", import.meta.dirname);
// 当 src 目录文件变化后使其失效
registerHooks(hooks.useTree(tree));
// 保持最新
tree.watch().on("filesUpdated", doStuff);
doStuff();
// 现在导入 “src” 下的模块会重新执行
async function doStuff() {
const {stuff} = await import("src/dostuff.js");
// "stuff" is never stale
}
之前,immaculata 提供的功能与 Vite 相同,即使用内置的 node:vm 功能创建一个位于 Node 之上的临时模块系统,并使用自定义逻辑粘合各个系统,相当于创建了二等模块。
该方式的主要的缺点是逻辑重复且相互独立。
- 重复:体现在查找、加载、解析文件、执行和存储模块对象,以及将所有模块相互粘合,还要考虑与 Node 自身的模块系统粘合
- 独立:这些系统本质上是独立的,因此原生的 Node 模块钩子不会对临时系统产生影响
2. 使用 Node 内置的 node:module 热加载原理
现在,通过使用 Node 内置的 node:module 模块添加模块钩子 (Hooks),可以原生实现 “热模块 (HMR)” 功能。
- 首先,从磁盘加载源文件并将其保存在内存中,且不会影响大多数项目和开发机器的内存限制。为了满足这一需求,创建了一个 FileTree 类,其除了将文件树加载到内存之外不执行其他操作,并可选地通过 .watch() 保持文件树的更新,该函数返回一个带有 filesUpdated 事件的 EventEmitter。Node 的原生文件监听器 ( File Watcher) 可以节省磁盘空间,并返回需要的所有信息,因此开发者不再需要 chokidar 。
- 提供了 useTree 双钩子 (dual-hook),其执行两项关键操作,包括:一个加载器钩子,使用 tree.files.get 而不是 fs.readFileSync 返回源字符串。同时添加了一个解析器钩子,将 ?ver=${file.version} 附加到任何给定模块的 URL 之后。
- 使用 FileTree 构造函数和 watch 方法将每个文件的版本设置为 Date.now(),因为 Node 内部使用 URL 来表示所有模块。
实际上,这意味着开发者可以先导入一个模块文件,然后在 filesUpdated 事件之后再次导入同一文件。此时,要么返回缓存的模块对象,要么重新执行该文件。
该难题唯一缺少的就是依赖关系树。由于模块钩子会在导入期间调用,因此可以使用此信息来注册依赖关系,由 FileTree 内部完成。每次模块的依赖关系发生变化时,父模块本身的版本也会更新。而且该过程是递归的,因此即使单个深层依赖关系发生变化,模块也始终是最新的。
3. 如何使用钩子
3.1 基础用法
import {FileTree} from "immaculata";
import {useTree} from "immaculata/hooks.js";
import {registerHooks} from "node:module";
const tree = new FileTree("src", import.meta.dirname);
registerHooks(useTree(tree));
const myModule = await import("src/myModule.js");
// src/myModule.js 被执行
const myModule2 = await import("src/myModule.js");
// src/myModule.js 第二次不会再执行
tree.watch().on("filesUpdated", async () => {
const myModule = await import("src/myModule.js");
// src/myModule.js 如果失效会自动再次执行
});
由于有了依赖关系树,开发者可以在更新其版本的同时轻松地抛出 moduleInvalidated 事件。而且由于依赖关系树本身就是对象,因此可以从需要在失效时清理资源的模块中导入。
import * as ShikiMd from "@shikijs/markdown-it";
import type MarkdownIt from "markdown-it";
import * as Shiki from "shiki";
import {tree} from "../../static.ts";
const highlighter = await Shiki.createHighlighter({
themes: ["dark-plus"],
langs: ["tsx", "typescript", "json", "yaml", "bash"],
});
export function highlightCode(md: MarkdownIt) {
md.use(ShikiMd.fromHighlighter(highlighter, { theme: "dark-plus"}));
}
// moduleInvalidated 事件
tree.onModuleInvalidated(import.meta.url, () => {
highlighter.dispose();
});
3.2 Node.js 中启用 JSX
默认情况下,原生 Node.js 模块系统:
- 拒绝将 .jsx 或 .tsx 文件视为可导入模块
- 无法将 JSX 语法转换为 JavaScript
而使用 immaculata,开发者可以:
- 使 Node.js 将 .jsx 和 .tsx 文件识别为有效模块
- 告诉 Node.js 如何将 JSX/TSX 转换为有效的 JavaScript
- 将默认的 react/jsx-runtime 重新映射到其他模块
import {compileJsx} from "immaculata/hooks.js"
import {registerHooks} from "module"
import ts from 'typescript'
import {fileURLToPath} from "url"
// 当 Node.js 加载时会自动将 tsx 转化为 JavaScript
registerHooks(compileJsx((str, url) =>
ts.transpileModule(str, {
fileName: fileURLToPath(url),
compilerOptions: {
target: ts.ScriptTarget.ESNext,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.ReactJSX,
sourceMap: true,
inlineSourceMap: true,
inlineSources: true,
}
}).outputText
))
3.3 MD 文件支持 SSG
按照用 markdown 编写每个站点的悠久传统,可以借助于下面代码实现了简单构建工具指南中引用的 processSite。
import { Pipeline, type FileTree } from 'immaculata'
import { md } from "./markdown.ts"
import { template } from "./template.tsx"
export function processSite(tree: FileTree) {
const files = Pipeline.from(tree.files)
// make `site/public/` be the file tree
files.without('/public/').remove()
files.do(f => f.path = f.path.slice('/public'.length))
// find all .md files and render in a jsx template
files.with(/\.md$/).do(f => {
f.path = f.path.replace('.md', '.html')
f.text = template(md.render(f.text))
})
return files.results()
}
参考资料
https://immaculata.dev/blog/native-nodejs-hmr.html
https://github.com/sdegutis/immaculata
https://medium.com/@jircdev/hmr-in-node-with-beyondjs-4869060c9d83