React Router v7 已發布。 查看文件
手動開發伺服器
本頁內容

手動模式

本指南僅在使用經典 Remix 編譯器時相關。

預設情況下,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 編譯器管理的熱更新
  • 在您的應用程式伺服器內以開發模式執行的實際生產程式碼路徑

remix-serve

Remix 應用程式伺服器(remix-serve)預設支援手動模式

remix dev --manual

如果您在沒有 -c 旗標的情況下執行 remix dev,則您隱式地使用 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 中,沒有辦法從 import 快取中移除項目。雖然我們的時間戳記因應措施有效,但這表示 import 快取會隨著時間推移而增長,最終可能會導致記憶體不足錯誤。

如果發生這種情況,您可以重新啟動 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. 發送「ready」訊息

現在是個好時機,再次確認您的應用程式伺服器在初始啟動時,是否有向 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);
}

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

太棒了!現在讓我們在開發模式下運行時,加入我們新的手動傳輸。

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 實用程式,如果您喜歡的話,可以幫助您。

文件和範例以 MIT 授權