iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Modern Web

《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記系列 第 24 篇

Day 24|脫離掌控的回應:為什麼用了 @Res(),Interceptor 的轉換結果不見了?

  • 分享至 

  • xImage
  •  

在開發 NestJS 時,為了統一 API 回傳格式,團隊通常會註冊全域 Interceptor,建立一致的回應結構。原本 API 都依循這個規則,直到某次為了處理自訂 Header 或 Cookie,我們在 Controller 中使用了原生 Express 的 @Res()。

資料順利傳送,但這時前端卻回報說:

「這支 API 回傳的資料結構跑掉了!」

有些人遇到這個問題時,可能會誤會:

「是不是只要使用了 @Res(),NestJS 的 Interceptor 就會被強制停用?」

但實際上並非如此。這篇文章,我們將拆解 @Res() 出現時,底層的回應機制究竟發生了什麼變化。

問題怎麼發生?

為了觀察這個問題,我們先建立一個全域 EnvelopeInterceptor,在進入點加上一個自訂 Header,作為驗證 Interceptor 是否真的有被觸發執行的觀察點,並透過 RxJS 的 map() 將 Handler 回傳的值包進 data 中:

import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import type { Response } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface ResponseEnvelope<T> {
  data: T;
}

@Injectable()
export class EnvelopeInterceptor<T>
  implements NestInterceptor<T, ResponseEnvelope<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler<T>,
  ): Observable<ResponseEnvelope<T>> {
    const response = context.switchToHttp().getResponse<Response>();

    // 觀察點:透過 Header 確認 Interceptor 是否有執行
    response.setHeader('x-envelope-interceptor', 'entered');

    return next.handle().pipe(
      map((data) => {
        console.log('interceptor after:', data);

        return { data };
      }),
    );
  }
}

接著透過 APP_INTERCEPTOR 進行全域註冊:

@Module({
  controllers: [PostsController],
  providers: [
    {
      provide: APP_INTERCEPTOR,
      useClass: EnvelopeInterceptor,
    },
  ],
})
export class AppModule {}

為了方便比較,下面幾個端點都會回傳同一筆資料:

export const DEMO_POST: Post = {
  id: 24,
  title: '第一篇文章',
};

對照一:標準模式

我們先看標準的 API 寫法:

@Get()
getPost(): Post {
  return DEMO_POST;
}

發送請求:

GET /posts

用戶端收到的 Response Body 為:

{
  "data": {
    "id": 24,
    "title": "第一篇文章"
  }
}

這完全符合預期。Controller 只負責 return 資料,後續的 Interceptor 順利完成加工,最後由 NestJS 自動寫回 HTTP 回應。

對照二:注入 @Res() 模式

接著,我們改用注入 @Res() 的寫法進行對照:

@Get('manual')
getPostWithExpressResponse(@Res() response: Response): Post {
  response.json(DEMO_POST);

  // 刻意保留 return,觀察 Interceptor 能否取得 Handler 結果
  return DEMO_POST;
}

發送請求:

GET /posts/manual

用戶端收到的 Response Body 卻是:

{
  "id": 24,
  "title": "第一篇文章"
}

原本的 data 包裝確實消失了。但如果檢查回應標頭(Response Header),會發現:

x-envelope-interceptor: entered

這個 Header 標記 依然存在!

這證實了 Interceptor 確實有執行,而 map() 中的 console.log() 則能進一步確認 Handler 的回傳值仍然有流回 Interceptor。

既然 Interceptor 有執行、資料也有轉換,為什麼最終送到用戶端手上的卻是未經包裝的原始資料?

根因:@Res() 讓 Nest 略過標準回應處理

問題的關鍵不在於 Interceptor 有沒有執行,而是最終是由「誰」負責把 HTTP 回應送出去。

1. 標準模式(Standard Approach)

在沒有 @Res() 的情況下,Controller 的 return 並不會直接寫入 HTTP 回應,而是把控制權交還給 NestJS:

https://ithelp.ithome.com.tw/upload/images/20261008/20184306nKrvnuDSrs.png

Controller 決定「回應內容是什麼」,NestJS 負責「最終如何發送」。

2. 原生框架模式(Library-specific Approach)

當你在 Controller 參數注入了 @Res() 並且主動呼叫 response.json() 時,這個分工流程就改變了:

https://ithelp.ithome.com.tw/upload/images/20261008/20184306Rch4LhpLrf.png

當 response.json() 被呼叫的那一刻,底層的 Express 就已經將 HTTP Response Body 發送給用戶端。雖然 Interceptor 隨後依然從 return 接到了資料並執行 map() 轉換,但這份加工後的資料已經無法再送出了。

NestJS 如何判定處理權?

NestJS 又是如何判斷自己「不需要再處理 Response」的?它並不是在執行到 response.json() 時才動態阻擋,而是在初始化路由階段,就已確立明確的處置流程。

NestJS 會檢查 Handler 是否使用了 @Res() 或 @Next(),以及 @Res() 是否開啟 passthrough。

底層的核心判斷邏輯可簡化如下:

const isResponseHandled =
  hasResponseOrNextDecorator && !isPassthroughEnabled;

const result = await runInterceptorsAndHandler();

if (!isResponseHandled) {
  await applyResponse(result);
}

這裡的 applyResponse() 是為了說明流程而使用的簡化名稱,並不是 Nest Core 裡實際存在的方法名稱。

總結來說:@Res() 改變的從來不是 Interceptor 的執行與否,而是「HTTP 回應的主導權與送出責任」。

排雷指南:依 Response 控制需求選擇寫法

情境一:一般資料直接 return(推薦預設)

如果只是一般的 JSON API,不需要直接操作底層 Response,維持 Controller 的標準寫法即可:

@Get()
getPost(): Post {
  return DEMO_POST;
}

保持 Controller 的純粹性,能確保全域 Response Envelope、Serialization 機制正常運作,在進行單元測試時也不需要模擬 Express 的 Response 物件。

情境二:需要局部設定,使用 Passthrough 模式

有些情況需要操作 Response,例如設定自訂 Header 或 Cookie,但 Response Body 仍然希望交給 NestJS 處理。這時可以使用 @Res({ passthrough: true }):

@Get('passthrough')
getPostWithPassthrough(
  @Res({ passthrough: true }) response: Response,
): Post {
  response.setHeader('x-demo-mode', 'passthrough');
  response.cookie('sessionId', 'xyz123');

  // 依然透過 return 將 Response Body 交付給 NestJS
  return DEMO_POST;
}

如此設定後,x-demo-mode Header 會順利帶上,用戶端也能收到經 Interceptor 包裝後的 data 結構。

⚠️ 注意:
使用 passthrough: true 時,請勿呼叫 response.json() 或 response.send(),否則會導致回應重複寫入或發送錯誤。

情境三:完全主導 Response

當需求真的需要直接操作底層 HTTP Response,例如直接控制原生 Stream、自行決定 Response 的送出時機時,可以考慮使用一般的 @Res():

@Get('download')
downloadFile(@Res() response: Response) {
  response.setHeader('Content-Type', 'application/pdf');
  fileStream.pipe(response);
}

採用此模式意味著開發者需完全承擔 Response 的處理責任(包含 HTTP 狀態碼、Headers、Response Body 與 Stream 關閉)。

需求場景 建議寫法 Response Body 由誰送出 Nest Response Mapping / Serialization
一般 JSON API 直接 return 資料 NestJS ✅
需要設定 Cookie / Header @Res({ passthrough: true }) + return NestJS ✅
完全主導回應 @Res() + response.send() / pipe() Controller (Express) ❌

延伸陷阱:Serialization 結果無法套用到已送出的 Response

後處理機制失效所帶來的影響,不僅限於回應格式包裝,在安全性上更值得關注的是 ClassSerializerInterceptor 的失效。

假設在 Entity 中定義了敏感欄位:

export class UserEntity {
  id: number;

  name: string;

  @Exclude()
  password: string;
}

在標準 return 流程下,ClassSerializerInterceptor 會自動將標註 @Exclude() 的 password 剔除。但如果直接使用 @Res() 送出:

@Get('user')
getUser(@Res() response: Response) {
  const user = new UserEntity(...);
  
  // 注意:原始 user 物件會被直接序列化送出
  response.json(user);
}

此時 Express 會直接將包含 password 的原始物件序列化並傳送給用戶端。即使後續 Interceptor 仍然完成資料轉換,也無法回頭修改已經送出的 Response。

總結

  1. @Res() 不會讓 Interceptor 失效: 使用 @Res() 並不代表 Interceptor 不會執行,它們依然會在 Controller 執行前後運作,next.handle() 也同樣能拿到回傳值。
  2. @Res() 會改變 Response 的控制權: 使用未開啟 passthrough 的 @Res() 時,NestJS 便會預設由開發者自行接管回應流程,最後不再透過標準流程將 Handler Result 寫入 Response。
  3. 善用 Passthrough 模式: 如果僅需調整 Header 或 Cookie,使用 @Res({ passthrough: true }) 即可在保留原生 Response 操作權限的同時,將資料發送與後處理責任繼續交由 NestJS 處理。

參考資料


上一篇
Day 23|消失的回滾:ROLLBACK 了資料卻還在?別讓預設的 Repository 偷溜出去
系列文
《NestJS 絕地求生手冊》:我用一年血淚換來的實戰排雷筆記 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言