北屋教程网

专注编程知识分享,从入门到精通的编程学习平台

是时候抛弃 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

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言