Tác giả:
    Ngày tạo:2026-08-29Cập nhật lần cuối:2026-08-29

    Dịch ứng dụng htmx của bạn bằng Intlayer | Quốc tế hóa (i18n)

    htmx không render bất kỳ nội dung nào của riêng nó. Mọi nhãn mà khách truy cập đọc được đều là HTML mà máy chủ của bạn tạo ra, và mọi swap là một yêu cầu HTTP riêng biệt. Quốc tế hóa một ứng dụng htmx do đó là một mối quan tâm của máy chủ: locale phải được giải quyết trên mỗi yêu cầu, và mỗi fragment phải được render ở locale đó.

    Intlayer giải quyết điều này thông qua các backend integrations của nó, chúng phát hiện locale cho mỗi yêu cầu và expose nội dung khai báo của bạn cho handler xây dựng HTML.

    Mục lục

    Ba quy tắc của i18n trong một ứng dụng htmx

    Một trang có thể kích hoạt hàng chục swaps. Mỗi cái là một yêu cầu mới không có bộ nhớ về trang đã phát hành nó. Nếu locale nằm trong một biến được đặt trong quá trình render ban đầu, mọi fragment sau đó sẽ quay lại ngôn ngữ mặc định.

    Middleware Intlayer giải quyết locale từ chính yêu cầu đó, vì vậy một fragment được phục vụ tại phút mười trả lời cùng ngôn ngữ với trang được phục vụ tại phút không.

    Hai trình vận chuyển hoạt động với htmx. Một cookie (INTLAYER_LOCALE) được gửi bởi trình duyệt tự động trên mỗi yêu cầu, bao gồm các yêu cầu htmx. Một header (x-intlayer-locale) có thể được đính kèm vào các yêu cầu htmx với thuộc tính hx-headers. Cả hai đều được đọc theo mặc định.

    Một giá trị được dịch nội suy vào một fragment là markup. Escape nó, giống như bạn sẽ làm với bất kỳ giá trị động nào khác, vì vậy một bản dịch chứa < không thể phá vỡ tài liệu mà nó được hoán đổi vào.


    Hướng Dẫn Từng Bước

    ide.intlayer.org

    Xem Application Template trên GitHub.

    1. Cài đặt Dependencies

      Cài đặt intlayer cùng với integration cho server của bạn.

      bash
      npm install intlayer express-intlayer cookie-parser
      
      bash
      npm install intlayer fastify-intlayer @fastify/cookie @fastify/formbody
      
      bash
      npm install intlayer hono-intlayer
      
      bash
      npm install intlayer elysia-intlayer
      
      Express và Fastify đọc cookie locale thông qua các cookie parser của riêng họ, vì vậy chúng phải được cài đặt cùng với. Hono và Elysia parse cookies một cách native.

      htmx chính nó là một single script tag, được thêm vào bước 4.

    2. Cấu hình dự án của bạn

      Tạo một intlayer.config.ts ở root của dự án:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        internationalization: {
          locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH, Locales.ARABIC],
          defaultLocale: Locales.ENGLISH,
        },
      };
      
      export default config;
      
      Để xem danh sách đầy đủ các tùy chọn, hãy xem tài liệu cấu hình.
    3. Khai báo Nội dung của bạn

      Khai báo mọi nhãn mà máy chủ sẽ hiển thị, bao gồm cả những nhãn chỉ xuất hiện bên trong một fragment:

      src/app.content.ts
      import { insert, t, type Dictionary } from "intlayer";
      
      const appContent = {
        key: "app",
        content: {
          pageTitle: "Intlayer + htmx",
      
          localeLabel: t({
            vi: "Ngôn ngữ",
            en: "Language",
            fr: "Langue",
            es: "Idioma",
            ar: "اللغة",
          }),
      
          cartSummary: insert(
            t({
              vi: "Mục trong giỏ hàng của bạn: {{count}}",
              en: "Items in your cart: {{count}}",
              fr: "Articles dans votre panier : {{count}}",
              es: "Artículos en tu carrito: {{count}}",
              ar: "المنتجات في سلتك: {{count}}",
            })
          ),
      
          addItem: t({
            vi: "Thêm một mục",
            en: "Add an item",
            fr: "Ajouter un article",
            es: "Añadir un artículo",
            ar: "أضف منتجًا",
          }),
        },
      } satisfies Dictionary;
      
      export default appContent;
      
      Các khai báo nội dung có thể nằm ở bất kỳ đâu trong contentDir (theo mặc định là ./src) và khớp với .content.{json,ts,tsx,js,jsx,mjs,cjs,md,mdx,yaml,yml}. Xem tài liệu khai báo nội dung.
    4. Đăng ký middleware Intlayer

      Middleware giải quyết locale của mỗi request và hiển thị nó cho các handler của bạn.

      src/index.ts
      import cookieParser from "cookie-parser";
      import express from "express";
      import { intlayer } from "express-intlayer";
      
      const app = express();
      
      // Cookie parser phải chạy trước: `express-intlayer` đọc locale
      // cookie thông qua `req.cookies`.
      app.use(cookieParser());
      app.use(express.urlencoded({ extended: false }));
      app.use(intlayer());
      

      Locale đã được giải quyết là trên res.locals.locale.

      src/index.ts
      import cookie from "@fastify/cookie";
      import formbody from "@fastify/formbody";
      import Fastify from "fastify";
      import { intlayer } from "fastify-intlayer";
      
      const fastify = Fastify();
      
      await fastify.register(cookie);
      await fastify.register(formbody);
      await fastify.register(intlayer);
      

      Locale được phân giải nằm trên req.intlayer.locale.

      src/index.ts
      import { Hono } from "hono";
      import { intlayer } from "hono-intlayer";
      
      const app = new Hono();
      
      app.use("*", intlayer());
      

      Locale được phân giải là c.get("locale").

      src/index.ts
      import { Elysia } from "elysia";
      import { intlayer } from "elysia-intlayer";
      
      const app = new Elysia().use(intlayer());
      

      Locale đã được phân giải là intlayer!.locale trên bối cảnh route.

      Theo mặc định, locale được lấy từ cookie INTLAYER_LOCALE, sau đó là header x-intlayer-locale, sau đó là thương lượng Accept-Language.

    5. Render các fragment với locale của request

      Viết các renderer fragment của bạn dưới dạng pure function của một locale, và truyền locale mà middleware đã phân giải. Truyền nó một cách rõ ràng giúp giữ một fragment được liên kết với request đã yêu cầu nó, bất kể bạn đang ở server nào.

      src/views.ts
      import { currency, getIntlayer, type Locale } from "intlayer";
      
      const HTML_ENTITIES: Record<string, string> = {
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      };
      
      /** Thoát một giá trị đã dịch để nó không thể thoát ra khỏi markup. */
      const escapeHtml = (value: string): string =>
        value.replace(
          /[&<>"']/g,
          (character) => HTML_ENTITIES[character] ?? character
        );
      
      export const renderCart = (locale: Locale, itemCount: number): string => {
        const content = getIntlayer("app", locale);
      
        return `<section id="cart">
        <p>${escapeHtml(String(content.cartSummary({ count: itemCount })))}</p>
        <p>${escapeHtml(currency(itemCount * 12.5, { locale, currency: "EUR" }))}</p>
        <button
          hx-post="/cart/items"
          hx-vals='{"itemCount": ${itemCount}}'
          hx-target="#cart"
          hx-swap="outerHTML"
        >${escapeHtml(String(content.addItem))}</button>
      </section>`;
      };
      

      Phục vụ nó từ một route:

      src/index.ts
      app.post("/cart/items", (req, res) => {
        const itemCount = Number(req.body?.itemCount ?? 0) + 1;
      
        res.type("html").send(renderCart(res.locals.locale, itemCount));
      });
      
      src/index.ts
      fastify.post("/cart/items", async (req, reply) => {
        const itemCount =
          Number((req.body as { itemCount?: string })?.itemCount ?? 0) + 1;
      
        return reply
          .type("text/html")
          .send(renderCart(req.intlayer.locale, itemCount));
      });
      
      src/index.ts
      app.post("/cart/items", async (c) => {
        const body = await c.req.parseBody();
        const itemCount = Number(body["itemCount"] ?? 0) + 1;
      
        return c.html(renderCart(c.get("locale"), itemCount));
      });
      
      src/index.ts
      app.post("/cart/items", ({ body, intlayer }) => {
        const itemCount =
          Number((body as { itemCount?: string })?.itemCount ?? 0) + 1;
      
        return new Response(renderCart(intlayer!.locale, itemCount), {
          headers: { "content-type": "text/html" },
        });
      });
      

      Đoạn code tương tự hiện đã trả lời bằng tiếng Pháp cho một khách thăm có cookie fr, và bằng tiếng Ả Rập cho một khách có cookie ar, mà không có thay đổi nào trong markup gọi.

    6. Phục vụ trang đầu tiên

      Render <body> riêng biệt, để công tắc locale trong bước 7 có thể hoán đổi toàn bộ, sau đó bọc nó trong tài liệu mà tải htmx:

      src/views.ts
      import { getHTMLTextDir, getIntlayer, type Locale } from "intlayer";
      
      export const renderBody = (locale: Locale, itemCount: number): string => {
        const content = getIntlayer("app", locale);
      
        return `<body lang="${locale}" dir="${getHTMLTextDir(locale)}">
        <main>
          <h1>${escapeHtml(String(content.pageTitle))}</h1>
          ${renderLocaleSwitcher(locale)}
          ${renderCart(locale, itemCount)}
        </main>
      </body>`;
      };
      
      export const renderPage = (locale: Locale, itemCount: number): string =>
        `<!doctype html>
      <html lang="${locale}" dir="${getHTMLTextDir(locale)}">
      <head>
        <meta charset="utf-8" />
        <title>${escapeHtml(String(getIntlayer("app", locale).pageTitle))}</title>
        <script src="https://unpkg.com/htmx.org@2.0.4"></script>
      </head>
      ${renderBody(locale, itemCount)}
      </html>`;
      

      getHTMLTextDir trả về ltr, rtl hoặc auto cho locale, đó là những gì làm cho tiếng Ả Rập và tiếng Do Thái hiển thị bố cục một cách chính xác.

    7. Chuyển đổi ngôn ngữ

      Chuyển đổi ngôn ngữ là một yêu cầu như bất kỳ yêu cầu nào khác. Server lưu trữ lựa chọn trong cookie mà middleware đọc, sau đó trả về trang được hiển thị lại trong locale mới.

      Hiển thị bộ chọn ngôn ngữ dưới dạng select tự gửi và thay thế toàn bộ <body>, để các nhãn tĩnh xung quanh các fragment của bạn cũng thay đổi:

      src/views.ts
      import { getIntlayer, getLocaleName, type Locale, locales } from "intlayer";
      
      const renderLocaleSwitcher = (locale: Locale): string => {
        const content = getIntlayer("app", locale);
      
        const options = locales
          .map(
            (availableLocale: Locale) =>
              `<option value="${availableLocale}"${availableLocale === locale ? " selected" : ""}>${escapeHtml(getLocaleName(availableLocale, locale))}</option>`
          )
          .join("");
      
        return `<form>
        <label for="locale">${escapeHtml(String(content.localeLabel))}</label>
        <select
          id="locale"
          name="locale"
          hx-post="/locale"
          hx-trigger="change"
          hx-target="body"
          hx-swap="outerHTML"
        >${options}</select>
      </form>`;
      };
      
      getLocaleName(availableLocale, locale) ghi mỗi ngôn ngữ bằng ngôn ngữ hiện được hiển thị. Không truyền đối số thứ hai để ghi mỗi ngôn ngữ bằng chính ngôn ngữ của nó.

      Xử lý post bằng cách xác thực giá trị, đặt cookie và trả về body mới:

      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", (req, res) => {
        const requestedLocale = String(req.body?.locale);
      
        if (!isDeclaredLocale(requestedLocale)) {
          res.status(400).send("Unknown locale");
          return;
        }
      
        res.cookie("INTLAYER_LOCALE", requestedLocale, {
          sameSite: "lax",
          path: "/",
        });
        res.type("html").send(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      fastify.post("/locale", async (req, reply) => {
        const requestedLocale = String((req.body as { locale?: string })?.locale);
      
        if (!isDeclaredLocale(requestedLocale)) {
          return reply.status(400).send("Unknown locale");
        }
      
        return reply
          .setCookie("INTLAYER_LOCALE", requestedLocale, {
            sameSite: "lax",
            path: "/",
          })
          .type("text/html")
          .send(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { setCookie } from "hono/cookie";
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", async (c) => {
        // Phân tích body từ request
        const body = await c.req.parseBody();
        const requestedLocale = String(body["locale"]);
      
        // Kiểm tra xem locale có được khai báo không
        if (!isDeclaredLocale(requestedLocale)) {
          return c.text("Unknown locale", 400);
        }
      
        // Đặt cookie cho locale
        setCookie(c, "INTLAYER_LOCALE", requestedLocale, {
          sameSite: "Lax",
          path: "/",
        });
        return c.html(renderBody(requestedLocale, 0));
      });
      
      src/index.ts
      import { isDeclaredLocale } from "intlayer";
      
      app.post("/locale", ({ body, cookie, status }) => {
        const requestedLocale = String((body as { locale?: string })?.locale);
      
        if (!isDeclaredLocale(requestedLocale)) {
          return status(400, "Ngôn ngữ không xác định");
        }
      
        cookie["INTLAYER_LOCALE"]!.set({
          value: requestedLocale,
          sameSite: "lax",
          path: "/",
        });
      
        return new Response(renderBody(requestedLocale, 0), {
          headers: { "content-type": "text/html" },
        });
      });
      
      isDeclaredLocale thu hẹp một chuỗi tùy ý thành một trong các ngôn ngữ được cấu hình của bạn, do đó một giá trị không mong muốn không bao giờ đạt đến các renderer của bạn.
    8. Giữ lang và dir đồng bộ sau khi hoán đổi

      Tùy chọn

      Một swap có thể thay thế <body>, nhưng không bao giờ thay thế <html> xung quanh nó. Render langdir trên body được swap và sao chép chúng trở lại phần tử gốc một lần, từ head:

      src/views.ts
      <script>
        document.addEventListener("htmx:afterSwap", () => {
          document.documentElement.lang = document.body.lang;
          document.documentElement.dir = document.body.dir;
        });
      </script>
      

      Không có điều này, một sự chuyển đổi sang tiếng Ả Rập sẽ render từ phải sang trái bên trong body trong khi tài liệu vẫn quảng cáo ngôn ngữ trước đó cho công nghệ hỗ trợ và cho các crawler.

    9. Tùy chọn

      Nếu cookie không phù hợp với bạn, hãy đính kèm locale vào mọi yêu cầu htmx bằng hx-headers trên một phần tử tổ tiên. Các phần tử con sẽ kế thừa nó:

      html
      <body hx-headers='{"x-intlayer-locale": "fr"}'>
        ...
      </body>
      

      Middleware đọc x-intlayer-locale theo mặc định. Bạn có thể đổi tên cả hai carrier trong cấu hình của bạn:

      intlayer.config.ts
      import { Locales, type IntlayerConfig } from "intlayer";
      
      const config: IntlayerConfig = {
        // ... Các tùy chọn cấu hình khác
        routing: {
          storage: [
            { type: "header", name: "my-locale-header" },
            { type: "cookie", name: "my-locale-cookie" },
          ],
        },
      };
      
      export default config;
      

    Cấu hình TypeScript

    Bao gồm các loại được tự động tạo để một khóa không khai báo là lỗi biên dịch thay vì một chuỗi trống tại thời gian chạy.

    tsconfig.json
    {
      // ... Các cấu hình TypeScript hiện có của bạn
      "include": [
        // ... Các cấu hình TypeScript hiện có của bạn
        ".intlayer/**/*.ts", // Bao gồm các loại được tự động tạo
      ],
    }
    

    Cấu hình Git

    Nên bỏ qua các tệp được tạo bởi Intlayer:

    .gitignore
    # Bỏ qua các tệp được tạo bởi Intlayer
    .intlayer
    

    VS Code Extension

    Để cải thiện trải nghiệm phát triển với Intlayer, bạn có thể cài đặt Intlayer VS Code Extension chính thức.

    Cài đặt từ VS Code Marketplace

    Tiện ích mở rộng này cung cấp:

    • Tự động hoàn thành cho các khóa dịch.
    • Phát hiện lỗi thời gian thực cho các dịch bị thiếu.
    • Xem trước nội tuyến của nội dung đã dịch.
    • Hành động nhanh để dễ dàng tạo và cập nhật các bản dịch.

    Để biết thêm chi tiết về cách sử dụng tiện ích mở rộng, hãy tham khảo tài liệu Intlayer VS Code Extension.


    Đi xa hơn

    Để đi xa hơn, bạn có thể ngoại hóa nội dung của mình bằng cách sử dụng CMS, vì vậy các nhà dịch có thể thay đổi nội dung mà không cần triển khai.

    Các Câu Hỏi Thường Gặp

    Vì yêu cầu fragment không mang theo locale. htmx requests là độc lập với trang phát hành chúng, vì vậy locale phải được truyền trên mỗi yêu cầu, thông qua cookie INTLAYER_LOCALE hoặc header x-intlayer-locale được đặt với hx-headers. Kiểm tra rằng cookie parser chạy trước Intlayer middleware trên Express và Fastify, nếu không cookie sẽ không bao giờ được đọc và mọi yêu cầu sẽ quay lại Accept-Language.

    Hãy truyền nó. Các integrations expose locale được resolved (res.locals.locale, req.intlayer.locale, c.get("locale"), intlayer!.locale), và việc truyền nó tới getIntlayer làm cho mỗi renderer trở thành một pure function của một locale. Điều đó dễ dàng hơn để test, và nó giữ cho fragment renderers của bạn portable nếu bạn thay đổi server.

    Không. Mọi thứ mà một visitor nhìn thấy được produced bởi server, vì vậy không có gì để translate trong browser. Đó cũng là lý do tại sao page weight cost của i18n trong một htmx app gần như bằng không: không có catalog nào được shipped tới client.

    Phục vụ các trang của bạn dưới một tiền tố locale (/fr/cart) và đọc locale từ đường dẫn trong trình xử lý route của bạn, thay vì từ cookie, để render toàn bộ trang. Các fragment có thể tiếp tục sử dụng cookie hoặc header. Xem configuration để biết các tùy chọn định tuyến và custom URL rewrites.

    getHTMLTextDir(locale) trả về ltr, rtl hoặc auto. Đặt nó trên document cho lần render ban đầu, và áp dụng lại sau khi swap như bước 8 chỉ ra. Sử dụng các thuộc tính CSS logic (margin-inline-start thay vì margin-left) để bố cục của bạn tuân theo.

    Có, đối với bất kỳ thứ gì bạn nội suy vào một template string, giống như đối với bất kỳ giá trị động nào khác. Nội dung đến từ CMS hoặc từ một người dịch không phải là markup mà bạn kiểm soát. Bước 5 cho thấy một hàm escape tối thiểu.