手动开发服务器
On this page

手动模式

¥Manual mode

本指南仅适用于使用 Classic Remix 编译器 的情况。

默认情况下,remix dev 的运行方式类似于自动运行。它会在检测到应用代码中的文件更改时自动重启应用服务器,从而使你的应用服务器与最新的代码更改保持同步。这是一种简单且不会妨碍你的方法,我们认为它适用于大多数应用。

¥By default, remix dev drives like an automatic. It keeps your app server up to date with the latest code changes by automatically restarting the app server whenever file changes are detected in your app code. This is a simple approach that stays out of your way, and we think it will work well for most apps.

但是如果应用服务器重启拖慢了你的速度,你可以像手动操作一样掌控 remix dev

¥But if app server restarts are slowing you down, you can take the wheel and drive remix dev like a manual:

remix dev --manual -c "node ./server.js"

这意味着要学习如何使用离合器来换挡。这也意味着你可能会在找到方向时停滞不前。这需要更多时间学习,而且需要维护的代码也更多。

¥That means learning how to use the clutch to shift gears. It also means you might stall while you're getting your bearings. It takes a bit more time to learn, and it's more code for you to maintain.

能力越大,责任越大。

¥With great power comes great responsibility.

除非你觉得默认的自动模式有些不方便,否则我们认为这不值得。但是,如果你这样做,Remix 可以帮你解决。

¥We don't think it's worth it unless you're feeling some pain with the default automatic mode. But if you are, Remix has got you covered.

remix dev 的思维模型

¥Mental model for remix dev

在你开始飙车之前,了解 Remix 的底层工作原理会有所帮助。尤其重要的是要了解 remix dev 启动的不是一个进程,而是两个进程:Remix 编译器和你的应用服务器。

¥Before you start drag racing, it helps to understand how Remix works under the hood. It's especially important to understand that remix dev spins up not one, but two processes: the Remix compiler and your app server.

查看我们的视频 "新开发流程的思维模型 🧠" 了解更多详情。

¥Check out our video "Mental model for the new dev flow 🧠" for more details.

之前,我们将 Remix 编译器称为 "new dev server" 或 "v2 dev server"。从技术上讲,remix dev 是 Remix 编译器的一个薄层,它包含一个带有单个端点(/ping)的微型服务器,用于协调热更新。但将 remix dev 视为 "dev server" 是无益的,并且错误地暗示它会在开发环境中替换你的应用服务器。remix dev 并非替换你的应用服务器,而是将你的应用服务器与 Remix 编译器一起运行,因此你可以兼得两者的优势:- 由 Remix 编译器管理热更新 - 在你的应用服务器中运行在开发环境中的实际生产代码路径

remix-serve

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

¥The Remix App Server (remix-serve) comes with support for manual mode out of the box:

remix dev --manual

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

无需学习驾驶手动挡,因为 remix-serve 内置运动模式,可在更高转速下自动更积极地换挡。好的,我想我们有点夸大了汽车的比喻。😅

¥No need to learn to drive stick, since remix-serve has a built-in sports mode that automatically shifts gears for you more aggressively at higher RPMs. Ok, I think we're stretching this car metaphor. 😅

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

¥In other words, remix-serve knows how to reimport server code changes without needing to restart itself. But if you are using -c to run your own app server, read on.

学习驾驶手动挡汽车

¥Learning to drive a stick

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

¥When you switch on manual mode with --manual, you take on some new responsibilities:

  1. 检测服务器代码何时可用

    ¥Detect when server code changes are available

  2. 在保持应用服务器运行的同时重新导入代码更改

    ¥Re-import code changes while keeping the app server running

  3. 在获取这些更改后,向 Remix 编译器发送 "ready" 消息

    ¥Send a "ready" message to the Remix compiler after those changes are picked up

由于 JS 导入会被缓存,因此重新导入代码更改非常棘手。

¥Re-importing code changes turns out to be tricky because JS imports are cached.

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

¥You need some way to bust the import cache when you want to re-import modules with code changes. Also importing modules is different between CommonJS (require) and ESM (import) which complicates things even more.

如果你使用 tsxts-node 运行 server.ts,这些工具可能会将你的 ESM TypeScript 代码转译为 CJS JavaScript 代码。在这种情况下,即使其余服务器代码使用 import,你也需要在 server.ts 中使用 CJS 缓存清除。这里重要的是服务器代码的执行方式,而不是其编写方式。

1.a CJS:require 缓存清除

¥1.a CJS: require cache busting

CommonJS 使用 require 进行导入,让你直接访问 require 缓存。当重新构建时,你可以只清除服务器代码的缓存。

¥CommonJS uses require for imports, giving you direct access to the require cache. That lets you bust the cache for just the server code when rebuilds occur.

例如,以下是如何清除 Remix 服务器构建的 require 缓存:

¥For example, here's how to bust the require cache for the Remix server build:

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 缓存清除

¥1.b ESM: import cache busting

与 CJS 不同,ESM 不允许你直接访问导入缓存。要解决这个问题,你可以使用时间戳查询参数强制 ESM 将导入的内容视为新模块。

¥Unlike CJS, ESM doesn't give you direct access to the import cache. To work around this, you can use a timestamp query parameter to force ESM to treat the import as a new module.

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 可能会预先打包你的依赖,以保持导入缓存较小。

2. 检测服务器代码变更

¥ Detecting server code changes

现在你已经找到了打破 CJS 或 ESM 导入缓存的方法,是时候通过动态更新应用服务器中的服务器构建来将其付诸实践了。要检测服务器代码何时更改,你可以使用像 chokidar 这样的文件监视器:

¥Now that you have a way to bust the import cache for CJS or ESM, it's time to put that to use by dynamically updating the server build within your app server. To detect when the server code changes, you can use a file watcher like chokidar:

import chokidar from "chokidar";

async function handleServerUpdate() {
  build = await reimportServer();
}

chokidar
  .watch(VERSION_PATH, { ignoreInitial: true })
  .on("add", handleServerUpdate)
  .on("change", handleServerUpdate);

3. 发送 "ready" 消息

¥ Sending the "ready" message

现在是仔细检查你的应用服务器在初始启动时是否向 Remix 编译器发送了 "ready" 消息的好时机:

¥Now's a good time to double-check that your app server is sending "ready" messages to the Remix compiler when it initially spins up:

const port = 3000;
app.listen(port, async () => {
  console.log(`Express server listening on port ${port}`);

  if (process.env.NODE_ENV === "development") {
    broadcastDevReady(initialBuild);
  }
});

在手动模式下,你每次重新导入服务器版本时都需要发送 "ready" 消息:

¥In manual mode, you also need to send "ready" messages whenever you re-import the server build:

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. 开发感知请求处理程序

¥ Dev-aware request handler

最后一步是将所有这些封装到一个开发模式的请求处理程序中:

¥The last step is to wrap all of this up in a development mode request handler:

/**

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

太棒了!现在,让我们在开发模式下运行新的手动变速箱:

¥Awesome! Now let's plug in our new manual transmission when running in development mode:

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

完整的应用服务器代码示例,请查看我们的 templates社区示例

¥For complete app server code examples, check our templates or community examples.

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

¥Keeping in-memory server state across rebuilds

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

¥When server code is re-imported, any server-side in-memory state is lost. That includes things like database connections, caches, in-memory data structures, etc.

以下是一个实用程序,用于记住你希望在重建过程中保留的任何内存值:

¥Here's a utility that remembers any in-memory values you want to keep around across rebuilds:

// 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 客户端:

¥For example, to reuse a Prisma client across rebuilds:

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 实用程序 可以提供帮助。

¥There is also a handy remember utility that can help out here if you prefer to use that.

Remix v2.17 中文网 - 粤ICP备13048890号