默认情况下,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-serveRemix 应用服务器(remix-serve)开箱即用,支持手动模式
remix dev --manual
如果您在运行 remix dev 时没有使用 -c 标志,那么您就是在隐式地使用 remix-serve 作为您的应用服务器。
无需学习手动挡驾驶,因为 remix-serve 内置了运动模式,可以在更高的转速下更激进地自动换挡。好吧,我觉得我们这个汽车的比喻有点牵强了。😅
换句话说,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 代码。在这种情况下,即使您的其余服务器代码使用 import,您也需要在 server.ts 中使用 CJS 缓存清除。
这里重要的是您的服务器代码是如何执行的,而不是它是如何编写的。
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 编译器发送“ready”消息了
const port = 3000;
app.listen(port, async () => {
console.log(`Express server listening on port ${port}`);
if (process.env.NODE_ENV === "development") {
broadcastDevReady(initialBuild);
}
});
在手动模式下,您还需要在重新导入服务器构建时发送“ready”消息
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 实用程序 可以提供帮助。