For AI agents: the complete documentation index is available at /zh/llms.txt, the full documentation bundle is available at /zh/llms-full.txt, and this page is available as Markdown at /zh/guides/advanced-features/bff/operators.md.
  • 简体中文
  • 创建可扩展的 BFF 函数

    上一小节展示了如何在文件中导出一个简单的 BFF 函数。在更复杂的场景下,每个 BFF 函数可能需要做独立的类型校验,前置逻辑等。

    因此,Modern.js 暴露了 Api,支持通过该 API 来创建 BFF 函数,通过这种方式创建的 BFF 函数能方便的进行功能拓展。

    示例

    注意
    • Api 函数只能在 ts 项目中使用,无法在纯 js 项目中使用。
    • 操作符函数(如下述 Get,Query 等)依赖 zod,需要先在项目中安装。
    pnpm add zod

    一个由 Api 函数创建的 BFF 函数由以下几部分组成:

    • Api(),定义接口的函数。
    • Get(path?: string),指定接口路由。
    • Query(schema: T),Redirect(url: string),扩展接口,如指定接口入参。
    • Handler: (...args: any[]) => any | Promise<any>,接口处理请求逻辑的函数。

    服务端可以定义接口的入参与类型,根据类型,服务端在运行时会做自动的类型校验:

    api/lambda/user.ts
    import { Api, Post, Query, Data } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    const DataSchema = z.object({
      phone: z.string(),
    });
    
    export const addUser = Api(
      Post('/user'),
      Query(UserSchema),
      Data(DataSchema),
      async ({ query, data }) => ({
        name: query.name,
        phone: data.phone,
      }),
    );
    注意

    使用 Api 函数的文件,要保证所有的代码逻辑都放在函数内。如函数外做 console.log、使用 fs 等操作都是不允许的。

    浏览器端同样可以使用一体化调用的方式,拥有静态类型提示:

    routes/page.tsx
    import { addUser } from '@api/user';
    
    addUser({
      query: {
        name: 'modern.js',
        email: 'modern.js@example.com',
      },
      data: {
        phone: '12345',
      },
    });

    接口路由

    如下面示例,你可以通过 Get 函数指定路由和 HTTP Method:

    api/lambda/user.ts
    import { Api, Get, Query, Data } from '@modern-js/plugin-bff/server';
    
    // 指定接口路由,Modern.js 默认设置 `bff.prefix` 为 `/api`,
    // 因此该接口路由为 `/api/user`,Http Method 为 GET。
    export const getHello = Api(
      Get('/hello'),
      Query(HelloSchema),
      async ({ query }) => query,
    );

    当未指定路由时,接口路由根据文件约定定义,如下面示例,函数写法下,有代码路径 api/lambda/user.ts,会注册相应的接口 /api/user。

    api/lambda/user.ts
    import { Api, Get, Query, Data } from '@modern-js/plugin-bff/server';
    
    // 未指定接口路由,根据文件约定和函数名,该接口为 api/user,Http Method 为 get。
    export const get = Api(Query(UserSchema), async ({ query }) => query);
    Info

    Modern.js 推荐基于文件约定去定义接口,保持项目中路由清晰。具体规则可见函数路由。

    除了 Get 函数外,你可以使用以下函数定义 Http 接口:

    函数说明
    Get(path?: string)接受 Get 请求
    Post(path?: string)接受 POST 请求
    Put(path?: string)接受 PUT 请求
    Delete(path?: string)接受 DELETE 请求
    Patch(path?: string)接受 PATCH 请求
    Head(path?: string)接受 HEAD 请求
    Options(path?: string)接受 OPTIONS 请求

    请求

    以下为请求相关的操作符,操作符可以组合使用,但需符合 Http 协议,如 get 请求无法使用 Data 操作符。

    查询参数 Query

    使用 Query 函数可以定义 query 的类型,使用 Query 函数后,接口处理函数的入参中就可以拿到 query 信息,前端请求函数的入参中可以加入 query 字段:

    api/lambda/user.ts
    // 服务端代码
    import { Api, Query } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    export const get = Api(Query(UserSchema), async ({ query }) => ({
      name: query.name,
    }));
    routes/page.tsx
    // 前端代码
    get({
      query: {
        name: 'modern.js',
        email: 'modern.js@example.com',
      },
    });

    Query 参数类型转换

    URL query 参数默认是字符串类型,如果需要数字类型,需要使用 z.coerce.number() 进行类型转换:

    api/lambda/user.ts
    import { Api, Get, Query } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const QuerySchema = z.object({
      id: z.string(),
      page: z.coerce.number().min(1).max(100), // 使用 z.coerce.number() 转换字符串到数字
      status: z.enum(['active', 'inactive']),
    });
    
    export const getUser = Api(
      Get('/user'),
      Query(QuerySchema),
      async ({ query }) => {
        return {
          id: query.id,
          page: query.page, // page 是 number 类型
          status: query.status,
        };
      },
    );
    注意

    URL query 参数都是字符串类型,如果需要数字类型,需要使用 z.coerce.number() 进行转换,而不是直接使用 z.number()。

    传递数据 Data

    使用 Data 函数可以定义接口传递数据的类型,使用 Data 后,接口处理函数的入参中就可以拿到接口数据信息。

    Caution

    使用 Data 函数的话,必须遵循 HTTP 协议,HTTP Method 为 Get 或 Head 时,无法使用 Data 函数。

    api/lambda/user.ts
    import { Api, Data } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const DataSchema = z.object({
      name: z.string(),
      phone: z.string(),
    });
    
    export const post = Api(Data(DataSchema), async ({ data }) => ({
      name: data.name,
      phone: data.phone,
    }));
    routes/page.tsx
    // 前端代码
    post({
      data: {
        name: 'modern.js',
        phone: '12345',
      },
    });

    路由参数 Params

    路由参数可以实现动态路由,并且从路径中获取参数。可以通过 Params<T>(schema: z.ZodType<T>) 指定路径参数

    import { Api, Get, Params } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      id: z.string(),
    });
    
    export const queryUser = Api(
      Get('/user/:id'),
      Params(UserSchema),
      async ({ params }) => ({
        name: params.id,
      }),
    );

    请求头 Headers

    可以通过 Headers<T>(schema: z.ZodType<T>) 函数定义接口需要的请求头,并通过一体化调用传递请求头:

    import { Api, Headers } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const headerSchema = z.object({
      token: z.string(),
    });
    
    export const queryUser = Api(Headers(headerSchema), async ({ headers }) => ({
      name: headers.token,
    }));

    参数校验

    如前面提到的,当使用 Query,Data 等函数定义接口时,服务端会根据这些函数传入的 schema,对前端传入的数据做自动的校验。

    当校验失败时,可以通过 Try/Catch 捕获错误:

    try {
      const res = await postUser({
        query: {
          user: 'modern.js',
        },
        data: {
          message: 'hello',
        },
      });
      return res;
    } catch (error) {
      console.log(error.data.code); // VALIDATION_ERROR
      console.log(JSON.parse(error.data.message));
    }

    同时,可以通过 error.data.message 获取完整的错误信息:

    [
      {
        code: 'invalid_string',
        message: "Invalid email",
        path: [0, 'user'],
        validation: "email"
      },
    ];

    中间件 Middleware

    可以通过 Middleware 操作符设置函数中间件,函数中间件会在校验和接口逻辑之前执行。

    Info

    Middleware 操作符可以配置多次,中间件的执行顺序为从上至下

    import { Api, Query, Middleware } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    export const get = Api(
      Query(UserSchema),
      Middleware(async (c, next) => {
        console.info(`access url: ${c.req.url}`);
        await next();
      }),
      async ({ query }) => ({
        name: query.name,
      }),
    );

    数据转换 Pipe

    Pipe 操作符可以传入一个函数,在中间件和校验完成之后执行,主要有以下场景可以使用:

    1. 对请求携带的查询参数或数据进行转换。
    2. 对请求的数据进行自定义校验,如果校验失败,可以选择抛出异常,或者直接返回错误的信息。
    3. 希望只做校验,不执行接口逻辑,(如前端不做单独的校验,使用接口做校验,但在一些场景下又不希望接口逻辑执行)可以在此函数中终止后续的执行。

    Pipe 定义转换函数,转换函数的入参是接口请求携带的 query,data 和 headers,返回值会传递给下一个 Pipe 函数或接口处理函数作为入参,所以返回值的数据结构一般需和入参相同。

    Info

    Pipe 操作符可以配置多次,函数的执行顺序为从上至下,前一个函数的返回值,是后一个函数的入参。

    import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string(),
    });
    
    export const get = Api(
      Query(UserSchema),
      Pipe<{
        query: z.infer<typeof UserSchema>;
      }>(input => {
        const { query } = input;
        if (!query.email.includes('@')) {
          query.email = `${query.email}@example.com`;
        }
        return input;
      }),
      async ({ query }) => ({
        name: query.name,
      }),
    );

    同时,

    import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    export const get = Api(
      Query(UserSchema),
      Pipe<{
        query: z.infer<typeof UserSchema>;
      }>((input, end) => {
        const { query } = input;
        const { name, email } = query;
        if (!email.startsWith(name)) {
          return end({
            message: 'email must start with name',
          });
        }
        return input;
      }),
      async ({ query }) => ({
        name: query.name,
      }),
    );

    如果需要对响应做更多自定义操作,可以给 end 函数传入一个函数,函数的入参是 Hono 的 Context (c),可以对 c.req 和 c.res 进行操作:

    import { Api, Query, Pipe } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    export const get = Api(
      Query(UserSchema),
      Pipe<{
        query: z.infer<typeof UserSchema>;
      }>((input, end) => {
        const { query } = input;
        const { name, email } = query;
        if (!email.startsWith(name)) {
          return end(c => {
            c.res.status = 400;
            c.res.body = {
              message: 'email must start with name',
            };
          });
        }
        return input;
      }),
      async ({ query }) => ({
        name: query.name,
      }),
    );

    响应

    以下为响应相关操作符,通过响应操作符可以对响应进行处理。

    状态码 HttpCode

    可以通过 HttpCode(statusCode: number) 函数指定接口返回的状态码

    import { Api, Query, Data, HttpCode } from '@modern-js/plugin-bff/server';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    const DataSchema = z.object({
      phone: z.string(),
    });
    
    export const post = Api(
      Query(UserSchema),
      Data(DataSchema),
      HttpCode(202),
      async ({ query, data }) => {
        someTask({
          user: {
            ...query,
            ...data,
          },
        });
      },
    );

    响应头 SetHeaders

    支持通过 SetHeaders(headers: Record<string, string>) 函数设置响应头

    import { Api, Get, SetHeaders } from '@modern-js/plugin-bff/server';
    
    export default Api(
      Get('/hello'),
      SetHeaders({
        'x-log-id': 'xxx',
      }),
      async () => 'Hello World!',
    );

    重定向 Redirect

    支持通过 Redirect(url: string) 对接口做重定向:

    import { Api, Get, Redirect } from '@modern-js/plugin-bff/server';
    
    export default Api(
      Get('/hello'),
      Redirect('https://modernjs.dev/'),
      async () => 'Hello Modern.js!',
    );

    请求上下文

    如上面所述,通过操作符可以执行接口处理函数的入参,获得 query,data,params 等。但有时我们需要获得更多请求上下文的信息,此时可以通过 useHonoContext 获取:

    api/lambda/user.ts
    import { Api, Get, Query } from '@modern-js/plugin-bff/server';
    import { useHonoContext } from '@modern-js/server-runtime';
    import { z } from 'zod';
    
    const UserSchema = z.object({
      name: z.string().min(2).max(10),
      email: z.string().email(),
    });
    
    export const queryUser = Api(
      Get('/user'),
      Query(UserSchema),
      async ({ query }) => {
        const c = useHonoContext();
        const userAgent = c.req.header('user-agent');
        return {
          name: query.name,
          userAgent,
        };
      },
    );

    常见问题

    是否可以使用 ts 代替 zod schema

    如果你想使用 ts ,而不是 zod schema,可以使用 ts-to-zod,先将 ts 转为 zod schema,然后使用转换后的 schema。

    我们选用 zod ,而不是纯粹的 ts 定义入参类型信息的原因是:

    • zod 学习成本足够低。
    • 在校验这个场景,zod schema 拥有比 ts 更强的表达能力。
    • zod 更容易扩展。
    • 在运行时获取 ts 静态类型信息的方案都不够成熟。

    具体可以参考不同方案的比较,可以参考为什么使用 zod ,如果有更多的想法和疑问,也欢迎联系我们。

    更多实践

    为接口添加 http 缓存

    在前端开发中,有些服务端接口(如一些配置接口)响应时间会比较久,但其实长时间无需更新,针对这类接口我们可以设置 HTTP 缓存以提高页面的性能:

    import { Api, SetHeaders } from '@modern-js/plugin-bff/server';
    
    export const get = Api(
      // 缓存使用一体化调用或者 fetch 进行请求才会生效
      // 在 1s 内,缓存不做验证,直接返回响应
      // 1s-60s 内获取先返回旧的缓存信息,同时重新发起验证请求,使用新值填充缓存
      SetHeaders({
        'Cache-Control': 'max-age=1, stale-while-revalidate=59',
      }),
      async () => {
        await wait(500);
        return 'Hello Modern.js';
      },
    );