默认情况下,remix dev
就像自动挡一样驾驶。它通过在检测到应用程序代码中的文件更改时自动重新启动应用程序服务器来使您的应用程序服务器与最新的代码更改保持同步。这是一种简单的方法,不会妨碍您,我们认为它适用于大多数应用程序。
但是,如果应用程序服务器重新启动速度过慢,您可以接管方向盘,像手动挡一样驾驶 remix dev
。
remix dev --manual -c "node ./server.js"
这意味着学习如何使用离合器换挡。这也意味着您可能在熟悉它的时候会熄火。学习需要更多时间,并且您需要维护更多代码。
能力越大,责任越大。
我们认为,除非您在使用默认的自动模式时遇到了某些问题,否则这样做并不值得。但是,如果您遇到了问题,Remix 可以为您提供帮助。
remix dev
的思维模型在开始飙车之前,了解 Remix 的内部机制非常有帮助。尤其重要的是要了解 remix dev
启动的不是一个而是两个进程:Remix 编译器和您的应用程序服务器。
请查看我们的视频 "新开发流程的思维模型 🧠" 以获取更多详细信息。
之前,我们将 Remix 编译器称为“新开发服务器”或“v2 开发服务器”。从技术上讲,remix dev
是 Remix 编译器周围的一层薄薄的东西,它确实包含一个小型服务器,该服务器只有一个端点(/ping
)用于协调热更新。但将 remix dev
视为“开发服务器”并没有帮助,而且错误地暗示它正在开发中取代您的应用程序服务器。remix dev
并不是取代您的应用程序服务器,而是与 Remix 编译器一起运行您的应用程序服务器,因此您可以获得两全其美的结果。
remix-serve
Remix 应用程序服务器(remix-serve
)开箱即用地支持手动模式。
remix dev --manual
如果您在没有 -c
标志的情况下运行 remix dev
,则您隐式地使用 remix-serve
作为您的应用程序服务器。
无需学习手动挡驾驶,因为 remix-serve
具有内置的运动模式,该模式可以在更高的 RPM 下更加积极地自动换挡。好吧,我认为我们把这个汽车比喻用过头了。😅
换句话说,remix-serve
知道如何重新导入服务器代码更改,而无需重新启动自身。但是,如果您正在使用 -c
运行自己的应用程序服务器,请继续阅读。
当您使用--manual
启用手动模式时,您将承担一些新的责任
重新导入代码更改证明很棘手,因为 JS 导入是缓存的。
import fs from "node:fs";
const original = await import("./build/index.js");
fs.writeFileSync("./build/index.js", someCode);
const changed = await import("./build/index.js");
// ^^^^^^^ this will return the original module from the import cache without the code changes
您需要某种方法在想要重新导入带有代码更改的模块时清除导入缓存。此外,在 CommonJS (require
) 和 ESM (import
) 之间导入模块的方式不同,这使得事情更加复杂。
如果您使用tsx
或ts-node
来运行您的server.ts
,这些工具可能会将您的 ESM Typescript 代码转译为 CJS Javascript 代码。在这种情况下,您需要在您的server.ts
中使用 CJS 缓存清除,即使您的其他服务器代码使用import
。
这里重要的是您的服务器代码是如何执行的,而不是它是如何编写的。
require
缓存清除CommonJS 使用require
进行导入,让您可以直接访问require
缓存。这使您能够在重建发生时仅清除服务器代码的缓存。
例如,以下是如何清除 Remix 服务器构建的require
缓存
const path = require("node:path");
/** @typedef {import('@remix-run/node').ServerBuild} ServerBuild */
const BUILD_PATH = path.resolve("./build/index.js");
const VERSION_PATH = path.resolve("./build/version.txt");
const initialBuild = reimportServer();
/**
* @returns {ServerBuild}
*/
function reimportServer() {
// 1. manually remove the server build from the require cache
Object.keys(require.cache).forEach((key) => {
if (key.startsWith(BUILD_PATH)) {
delete require.cache[key];
}
});
// 2. re-import the server build
return require(BUILD_PATH);
}
require
缓存键是绝对路径,因此请确保将您的服务器构建路径解析为绝对路径!
import
缓存清除与 CJS 不同,ESM 不会让您直接访问导入缓存。为了解决这个问题,您可以使用时间戳查询参数来强制 ESM 将导入视为一个新的模块。
import * as fs from "node:fs";
import * as path from "node:path";
import * as url from "node:url";
/** @typedef {import('@remix-run/node').ServerBuild} ServerBuild */
const BUILD_PATH = path.resolve("./build/index.js");
const VERSION_PATH = path.resolve("./build/version.txt");
const initialBuild = await reimportServer();
/**
* @returns {Promise<ServerBuild>}
*/
async function reimportServer() {
const stat = fs.statSync(BUILD_PATH);
// convert build path to URL for Windows compatibility with dynamic `import`
const BUILD_URL = url.pathToFileURL(BUILD_PATH).href;
// use a timestamp query parameter to bust the import cache
return import(BUILD_URL + "?t=" + stat.mtimeMs);
}
在 ESM 中,无法从import
缓存中删除条目。虽然我们的时间戳解决方法有效,但它意味着import
缓存会随着时间的推移而增长,最终会导致内存不足错误。
如果发生这种情况,您可以重新启动remix dev
以使用新的导入缓存重新开始。将来,Remix 可能会预先捆绑您的依赖项以使导入缓存保持较小。
现在您已经有了清除 CJS 或 ESM 导入缓存的方法,是时候通过在您的应用程序服务器中动态更新服务器构建来利用它了。要检测服务器代码何时更改,您可以使用像chokidar这样的文件监视器
import chokidar from "chokidar";
async function handleServerUpdate() {
build = await reimportServer();
}
chokidar
.watch(VERSION_PATH, { ignoreInitial: true })
.on("add", handleServerUpdate)
.on("change", handleServerUpdate);
现在是时候仔细检查您的应用程序服务器在最初启动时是否向 Remix 编译器发送“已准备就绪”消息了
const port = 3000;
app.listen(port, async () => {
console.log(`Express server listening on port ${port}`);
if (process.env.NODE_ENV === "development") {
broadcastDevReady(initialBuild);
}
});
在手动模式下,您还需要在重新导入服务器构建时发送“已准备就绪”消息
async function handleServerUpdate() {
// 1. re-import the server build
build = await reimportServer();
// 2. tell Remix that this app server is now up-to-date and ready
broadcastDevReady(build);
}
最后一步是在开发模式请求处理程序中将所有这些封装起来
/**
* @param {ServerBuild} initialBuild
*/
function createDevRequestHandler(initialBuild) {
let build = initialBuild;
async function handleServerUpdate() {
// 1. re-import the server build
build = await reimportServer();
// 2. tell Remix that this app server is now up-to-date and ready
broadcastDevReady(build);
}
chokidar
.watch(VERSION_PATH, { ignoreInitial: true })
.on("add", handleServerUpdate)
.on("change", handleServerUpdate);
// wrap request handler to make sure its recreated with the latest build for every request
return async (req, res, next) => {
try {
return createRequestHandler({
build,
mode: "development",
})(req, res, next);
} catch (error) {
next(error);
}
};
}
太棒了!现在让我们在开发模式下运行时插入新的手动变速器
app.all(
"*",
process.env.NODE_ENV === "development"
? createDevRequestHandler(initialBuild)
: createRequestHandler({ build: initialBuild })
);
有关完整的应用程序服务器代码示例,请查看我们的模板或社区示例.
当服务器代码被重新导入时,任何服务器端的内存中状态都会丢失。这包括数据库连接、缓存、内存中数据结构等。
这是一个实用程序,它会记住您希望在重建之间保留的任何内存中值
// Borrowed & modified from https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts
// Thanks @jenseng!
export const singleton = <Value>(
name: string,
valueFactory: () => Value
): Value => {
const g = global as any;
g.__singletons ??= {};
g.__singletons[name] ??= valueFactory();
return g.__singletons[name];
};
例如,要跨重建重用 Prisma 客户端
import { PrismaClient } from "@prisma/client";
import { singleton } from "~/utils/singleton.server";
// hard-code a unique key so we can look up the client when this module gets re-imported
export const db = singleton(
"prisma",
() => new PrismaClient()
);
还有一个方便的remember
实用程序可以在这里提供帮助,如果您更喜欢使用它。