跳转到内容

Manual Dev Server

手动模式

本指南仅在使用 Classic Remix 编译器 时相关。

默认情况下,remix dev 就像自动驱动一样。 每当检测到应用代码中的文件更改时,它都会自动重新启动应用服务器,从而使您的应用服务器与最新的代码更改保持同步。 这是一种简单的方法,不会妨碍您,我们认为它适用于大多数应用。

但是如果应用服务器重启减慢了你的速度,你可以像手册一样掌控并驾驶remix dev

Terminal window
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 编译器管理的热更新
  • 在应用服务器中以开发方式运行的实际生产代码路径

remix-serve

Remix 应用服务器 (remix-serve) 支持开箱即用的手动模式:

Terminal window
remix dev --manual

<文档信息>

如果您在运行remix dev时没有使用-c标志,则您隐式使用remix-serve作为您的应用服务器。

无需学习驾驶手动挡汽车,因为 remix-serve 具有内置运动模式,可在更高转速下自动更积极地为您换挡。 好吧,我想我们正在延伸这个汽车比喻。😅

换句话说,remix-serve 知道如何重新导入服务器代码更改,而无需重新启动自身。 但如果您使用 -c 来运行自己的应用服务器,请继续阅读。

学习驾驶手动挡汽车

当您使用 --manual 开启手动模式时,您将承担一些新的职责:

  1. 检测服务器代码更改何时可用
  2. 在保持应用服务器运行的同时重新导入代码更改
  3. 在获取这些更改后向 Remix 编译器发送就绪消息

由于 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)之间的模块导入方式也不同,这让事情变得更加复杂。

<文档警告>

如果您使用tsxts-node来运行server.ts,这些工具可能会将您的 ESM Typescript 代码转换为 CJS Javascript 代码。 在这种情况下,您需要在server.ts中使用 CJS 缓存清除,即使其余服务器代码使用import

这里重要的是您的服务器代码如何_执行_而不是如何_编写_。

1.a 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 缓存键是_绝对路径_,因此请确保将服务器构建路径解析为绝对路径!

1.b ESM: 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 中,无法从导入缓存中删除条目。 虽然我们的时间戳解决方法有效,但这意味着导入缓存将随着时间的推移而增长,最终可能导致内存不足错误。

如果发生这种情况,您可以重新启动remix dev以使用新的导入缓存重新启动。 将来,Remix 可能会预先捆绑您的依赖项以保持导入缓存较小。

2. 检测服务器代码变更

现在您已经找到了一种打破 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);

3. 发送就绪消息

现在是时候仔细检查您的应用服务器在 Remix 编译器首次启动时是否向其发送了就绪消息:

server.js
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);
}

4. 开发感知请求处理程序

最后一步是将所有这些包装在开发模式请求处理程序中:

/**
* @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);
}
};
}

太棒了! 现在让我们在开发模式下运行时插入新的手动变速器:

server.js
app.all(
"*",
process.env.NODE_ENV === "development"
? createDevRequestHandler(initialBuild)
: createRequestHandler({ build: initialBuild })
);

有关完整的应用服务器代码示例,请查看我们的[模板][模板]或社区示例

在重建过程中保持内存服务器状态

重新导入服务器代码时,任何服务器端内存状态都会丢失。 其中包括数据库连接、缓存、内存数据结构等。

这是一个实用程序,它可以记住您想要在重建过程中保留的任何内存值:

app/utils/singleton.server.ts
// 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 客户端:

app/db.server.ts
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()
);

如果您愿意使用的话,还有一个方便的[记住实用程序][记住]可以在这里提供帮助。