首页
/ Novu Framework 工作流多语言(i18n)实战:用 subscriber.locale + i18next 在代码优先工作流中实现内容国际化

Novu Framework 工作流多语言(i18n)实战:用 subscriber.locale + i18next 在代码优先工作流中实现内容国际化

2026-09-08 14:17:27作者:邓越浪Henry

@novu/framework(Novu 的 code-first 工作流)把工作流以代码形式定义并部署在你自己的 Bridge 服务中,通知内容也在运行期由你的代码渲染。正因如此,i18n(国际化/翻译)职责天然落在应用层:你可以在代码中维护翻译 key(例如借助 i18next),并在 step resolver 内依据 subscriber.locale 决定为谁渲染哪种语言的邮件、短信或站内消息。读完本文你将掌握一套可落地的方案:安装并初始化 i18next、在工作流与邮件模板中注入翻译函数、打通 subscriber 的 locale 设置链路,最后完成本地化消息的同步与端到端验证。

需要先厘清一个概念边界:Novu Dashboard 内置的 Translation 翻译系统是针对 Dashboard 上可视化定义的工作流设计的;而 @novu/framework 的 code-first 工作流渲染发生在你的 bridge 进程中,因此应使用你自己的 i18n 技术栈(i18next、react-i18next、next-intl 等),二者不要混用。仓库中与 Dashboard 翻译系统对应的 API 参考见 docs/api-reference/translations 目录,可作为区分两种模式的对照资料。

为什么 code-first 工作流要把 i18n 放在应用层

从源码结构看,@novu/framework 的定位是"内容在你的 bridge 内、工作流执行期间渲染",框架只负责编排步骤与传递执行上下文:

这意味着"这条消息该用什么语言"是触发时刻、订阅者维度的动态事实,因此最佳实践是把翻译 key 的查找放在 resolver 内部,基于每个订阅者的 locale 实时完成——也就是下文的 i18next 方案。

基于 i18next 的多语言搭建

安装

npm install i18next

定义翻译资源

建议将翻译资源独立成模块,例如放在 src/novu/translations.ts。下面的示例使用 i18next 的 createInstance 创建一个独立实例(避免与项目中其他 i18next 使用方共享全局状态),并为 en_US / de_DE 两种 locale 分别定义同一组翻译 key:

// src/novu/translations.ts
import { createInstance, InitOptions } from "i18next";

const i18nOptions: InitOptions = {
  resources: {
    en_US: {
      translation: {
        welcomeEmailSubject: "Welcome to Twitch, {{username}}!",
        welcomeEmailIntroduction:
          "We're glad you could join us. Twitch has a huge, passionate community ready to watch and celebrate all the things you're into, and we've saved a seat just for you.",
        linkText: "WATCH NOW",
        welcomeEmailConclusion:
          "If you want to watch it, someone on Twitch streams it: games, anime, fitness, cosplay, esports, cooking, music, meditation. Take a look around, find a few channels to call home, and cozy up in chat.",
      },
    },
    de_DE: {
      translation: {
        welcomeEmailSubject: "Willkommen bei Twitch, {{username}}!",
        welcomeEmailIntroduction:
          "Wir freuen uns, dass Sie sich uns anschließen konnten. Twitch hat eine riesige, leidenschaftliche Community, die bereit ist, alles zu sehen und zu feiern, was Sie interessiert.",
        linkText: "JETZT ANSEHEN",
        welcomeEmailConclusion:
          "Wenn Sie es ansehen möchten, streamt es jemand auf Twitch.",
      },
    },
  },
};

const i18n = createInstance(i18nOptions);
i18n.init(i18nOptions);

export default i18n;

几个值得注意的细节:

  • createInstance + 显式 init:保证翻译实例与应用其他部分隔离,同时确认初始化在导出前完成,resolver 调用 getFixedT 时才不会拿到空资源。
  • 插值变量:key 中使用 {{username}} 这样的 i18next 插值占位符,稍后调用 t("welcomeEmailSubject", { username }) 时填充。
  • 资源结构:每个 locale 下包一层 translation 命名空间,是 i18next 的默认命名空间约定。

在工作流中使用翻译

@novu/frameworkworkflow() 定义中,email step 的 resolver 内部按如下方式解析文案:

import { workflow } from "@novu/framework";
import { z } from "zod";
import i18n from "./translations";
import { renderEmail } from "./emails/welcome";

export const localizedWorkflow = workflow(
  "welcome-localized",
  async ({ step, subscriber }) => {
    await step.email("email-step", async (controls) => {
      const t = i18n.getFixedT([
        subscriber?.locale || (controls.defaultLocale as string),
      ]);

      const subject = t("welcomeEmailSubject", {
        username: subscriber?.firstName || "Novu",
      });

      return {
        subject,
        body: await renderEmail(
          subject,
          t("welcomeEmailIntroduction"),
          t("linkText"),
          t("welcomeEmailConclusion")
        ),
      };
    }, {
      controlSchema: z.object({
        defaultLocale: z.string().default("en_US").optional(),
      }),
    });
  }
);

这段代码拆解出的模式可以直接复用:

  1. 读取订阅者语言:从 resolver 参数解构 subscriber,优先取 subscriber.locale
  2. 提供兜底默认值:用 controls.defaultLocale 作为 locale 缺失时的后备语言,并借助 zod 的 controlSchema 声明该控件(z.string().default("en_US").optional()),这样 Dashboard 上也会生成一个可编辑的 defaultLocale 控件。
  3. 固定语言实例i18n.getFixedT([locale]) 返回绑定到指定语言、可直接调用的 t 函数——与订阅者一一对应,避免并发触发时语言串扰。
  4. 插值传参t(key, params) 的第二个参数为 {{username}} 等插值提供值,例如把订阅者的 firstName(缺失时回退为 "Novu")填入邮件主题。
  5. 拼接 body:将翻译后的段落作为 props 传给 React Email 组件进行 HTML 渲染(renderEmail 见后文)。

subscriber.locale 是如何被设置的

locale 来自订阅者(subscriber)记录本身,在创建/更新订阅者时写入即可,格式遵循 BCP 47 且使用下划线约定(如 de_DE,而非 de-DE):

await novu.subscribers.create({
  subscriberId: "user-123",
  email: "jane@acme.com",
  locale: "de_DE", // ISO BCP 47 with underscore convention
});

也可以在触发时内联传入,覆盖(或补齐)订阅者记录上的语言:

await novu.trigger({
  workflowId: "welcome-localized",
  to: { subscriberId: "user-123", locale: "de_DE" },
  payload: {},
});

locale 缺失,resolver 会回退到 defaultLocale 控件值。在框架内部,这条链路有清晰的类型支撑:工作流上下文中的 Subscriber 结构定义在 packages/framework/src/types/subscriber.types.tslocale?: string | null),而触发载荷的订阅者负载 ISubscriberPayload 同样携带可选的 locale 字段,见 packages/framework/src/shared.ts。因此无论语言来自"订阅者档案"还是"单次触发的 to 对象",最终都会以 subscriber.locale 的形式出现在你的 resolver 上下文中。

React Email 模板示例

下面是一个典型的本地化邮件组件:它本身不感知任何 i18n 逻辑,只接收已翻译好的字符串作为 props,保证"翻译查找"与"样式渲染"两个关注点分离:

// src/novu/emails/welcome.tsx
import {
  Body,
  Container,
  Head,
  Html,
  Preview,
  Section,
  Text,
  Link,
  Img,
  Row,
  Column,
  render,
} from "@react-email/components";
import * as React from "react";

const baseUrl = process.env.IMAGE_BASE_URL;

export const TwitchWelcomeEmail = ({
  subject,
  body,
  linkText,
  body2,
}: {
  subject: string;
  body: string;
  linkText: string;
  body2: string;
}) => (
  <Html>
    <Head />
    <Preview>{subject}</Preview>
    <Body style={main}>
      <Container style={container}>
        <Section style={logo}>
          <Img width={114} src={`${baseUrl}/twitch-logo.png`} />
        </Section>
        <Section style={content}>
          <Text style={paragraph}>{body}</Text>
          <Section style={center}>
            <Link href="https://www.twitch.tv" style={link}>
              {linkText}
            </Link>
          </Section>
          <Text style={paragraph}>{body2}</Text>
        </Section>
      </Container>
    </Body>
  </Html>
);

const main = { backgroundColor: "#efeef1", fontFamily: "Helvetica, Arial, sans-serif" };
const paragraph = { lineHeight: 1.5, fontSize: 14 };
const container = { maxWidth: 580, margin: "30px auto", backgroundColor: "#ffffff" };
const content = { padding: "5px 20px 10px 20px" };
const logo = { display: "flex", justifyContent: "center", padding: 30 };
const center = { display: "flex", justifyContent: "center" };
const link: React.CSSProperties = {
  background: "#9147ff",
  color: "#fff",
  borderRadius: 3,
  display: "inline-block",
  fontSize: 18,
  padding: "10px 30px",
  textDecoration: "none",
};

export async function renderEmail(
  subject: string,
  body: string,
  linkText: string,
  body2: string
) {
  return render(
    <TwitchWelcomeEmail
      subject={subject}
      body={body}
      linkText={linkText}
      body2={body2}
    />
  );
}

要点说明:

  • 图片地址通过 process.env.IMAGE_BASE_URL 注入,静态资源应托管在你自己的对象存储/CDN 上;
  • 这里刻意省略了 Row / Column 的实际使用以保持示例精简,实际布局中可按需补充列排版;
  • 邮件预览文本(<Preview>)与主题共用翻译后的 subject,保证收件箱列表、邮件正文语言一致。

将工作流挂载到 Bridge

把本地化工作流注册到 Bridge 的 HTTP handler 中,使其可被触发。以 Next.js App Router 的 API route 为例:

// app/api/novu/route.ts
import { serve } from "@novu/framework/next";
import { localizedWorkflow } from "@/novu/workflows/welcome-localized";

export const { GET, POST, OPTIONS } = serve({
  workflows: [localizedWorkflow],
});

serve 来自 @novu/framework/next,负责暴露 Bridge 协议所需的 GET(工作流发现/握手)、POST(执行 step)、OPTIONS(CORS 预检)等端点。Bridge 服务启动后,工作流即可被同步并在触发时按订阅者语言渲染。

端到端测试

  1. 同步工作流到你的 Novu 环境(若尚未同步):

    npx novu@latest sync --bridge-url ... --secret-key ...
    
  2. 为订阅者设置不同 locale(例如把 user-123 更新为德语):

    await novu.subscribers.patch({ locale: "de_DE" }, "user-123");
    
  3. 触发工作流

    await novu.trigger({
      workflowId: "welcome-localized",
      to: "user-123",
      payload: {},
    });
    

验证预期:user-123 应收到一封 de_DE(德语) 的邮件——主题、正文段落、按钮文案均来自翻译资源中的 de_DE.translation

工程化建议(Tips)

  • Locale 命名约定统一:使用 ISO 639-1(语言) + ISO 3166-1(地区) 组合、下划线分隔,如 en_USde_DEpt_BR,并确保订阅者数据、触发载荷、i18next resources 的 key 三处完全一致。
  • 开发期对缺失翻译"硬失败":配置 i18next 的 saveMissingmissingKeyHandler,让 key 遗漏在开发阶段就以明显错误暴露,而不是带着漏译上线。
  • Digest(聚合)邮件单独设计:digest 邮件需要遍历一批事件生成内容,应构建一个接收翻译函数作为 prop 的本地化 React 组件,让组件在遍历事件时逐条调用 t
  • 翻译文件与工作流代码解耦:把翻译 JSON 与 TypeScript 代码分开维护,翻译同学可直接编辑 JSON,不触碰 TS。
  • 让非工程师也能改文案:若文案需要业务/翻译人员在不发布代码的情况下维护,可接入 i18next-http-backend + 你的 CMS 作为远程翻译资源源。

i18next 之外的备选方案

i18next 只是其中一种选择,任何 i18n 库都能与 code-first 工作流配合:

  • react-i18next:React Email 组件可以通过 i18next provider 模式在组件内部使用 hooks。
  • next-intl:若 bridge 跑在 Next.js 中,其服务端解析可以很好地工作于 bridge 进程内部。
  • @formatjs/intl:需要 ICU MessageFormat 语法(复数、选择等复杂规则)时的首选。
  • 朴素 Record<Locale, Record<Key, string>> 查表:当只需要少量字符串时,连 i18next 都不必引入,一个纯 TS 的二维映射 + 一次查表即可满足需求。

无论选择哪种库,核心设计不变:语言事实来自 subscriber.locale(或 defaultLocale 兜底),翻译查找发生在 bridge 内的 step resolver 中,模板只负责消费已翻译的字符串。这也是 code-first 工作流与 Dashboard 可视化工作流在 i18n 上的本质差异所在。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389