You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular Http拦截器缓存返回不可变响应引发只读属性错误

Angular HTTP拦截器缓存响应时出现"genre_ids is read-only"错误

问题场景

我使用Angular的HTTP拦截器实现请求缓存逻辑:若请求已发起过,拦截器直接返回缓存的HttpResponse。

触发缓存的API请求方法

getSeriesWithGenres() {
    const series$ = this.getSeries()
    const genres$ = this.getGenres()

    return forkJoin([series$, genres$]).pipe(
      map(([series, genres]) => {
        return series.map((serie: Serie) => {
          serie.genre_ids = serie.genre_ids.map(genreId => {
            return genres.find((genre: Genre) => genre.id === genreId)?.name
          })

          return serie
        })
      })
    )
}

发起HTTP请求的核心方法

getSeries() {
    return this.http
      .get<ApiResponse>(
        `
        https://api.themoviedb.org/3/tv/popular?api_key=${environment.apiKey}&language=en-US
        `
      )
      .pipe(map((data: ApiResponse) => data.results as Serie[]))
  }

getGenres() {
    return this.http
      .get(
        `
        https://api.themoviedb.org/3/genre/tv/list?api_key=${environment.apiKey}&language=en-US
        `
      )
      .pipe(map((data: any) => data.genres))
}

缓存服务的数据结构

private cache: Map<string, [Date | null, HttpResponse<unknown>]> = new Map()

该服务配有对应的getter和setter方法。

HTTP拦截器实现代码

const HALF_HOUR = 1800
const TIME_TO_LIVE = HALF_HOUR

@Injectable()
export class CachingInterceptor implements HttpInterceptor {
  constructor(private cacheResolver: CacheResolverService) {}
  intercept(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    if (request.method !== 'GET') {
      return next.handle(request)
    }
    console.log('CachingInterceptor: ' + request.url)
    const cachedResponse = this.cacheResolver.get(request.url)
    console.log('CachedResponse:' + JSON.stringify(cachedResponse?.body))
    return cachedResponse ? of(cachedResponse) : this.sendRequest(request, next)
  }

  sendRequest(
    request: HttpRequest<unknown>,
    next: HttpHandler
  ): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      map((event: HttpEvent<unknown>) => {
        if (event instanceof HttpResponse) {
          this.cacheResolver.set(request.url, event, TIME_TO_LIVE)
        }
        return event
      })
    )
  }
}

触发的错误

当getSeriesWithGenres()方法被调用两次时,抛出如下错误:

TypeError: "genre_ids" is read-only

问题根源

Angular的HttpResponse对象默认是**不可变(immutable)**的,缓存的响应体同样处于只读状态。第一次调用方法时,直接修改了响应体中的serie.genre_ids属性;第二次调用时,从缓存取出的是同一个只读对象,再次尝试修改该属性就会触发只读限制错误。

解决方案

方案1:修改数据时创建对象副本

在getSeriesWithGenres()的map操作中,不直接修改原响应对象,而是创建新的对象副本:

getSeriesWithGenres() {
    const series$ = this.getSeries()
    const genres$ = this.getGenres()

    return forkJoin([series$, genres$]).pipe(
      map(([series, genres]) => {
        return series.map((serie: Serie) => {
          // 展开原对象创建副本,避免修改只读的缓存对象
          return {
            ...serie,
            genre_ids: serie.genre_ids.map(genreId => {
              return genres.find((genre: Genre) => genre.id === genreId)?.name
            })
          }
        })
      })
    )
}

方案2:缓存时创建响应的可变副本

在拦截器缓存响应前,克隆HttpResponse并将响应体转为可变对象:

sendRequest(
    request: HttpRequest<unknown>,
    next: HttpHandler
): Observable<HttpEvent<unknown>> {
    return next.handle(request).pipe(
      map((event: HttpEvent<unknown>) => {
        if (event instanceof HttpResponse) {
          // 克隆响应并深拷贝响应体,创建可变副本
          const mutableResponse = event.clone({
            body: JSON.parse(JSON.stringify(event.body))
          })
          this.cacheResolver.set(request.url, mutableResponse, TIME_TO_LIVE)
          return mutableResponse
        }
        return event
      })
    )
}

注意:JSON.parse(JSON.stringify())是简易深拷贝方式,若响应体包含函数、循环引用等复杂结构,需替换为更可靠的深拷贝方法(如Lodash的cloneDeep)。

方案3:调整缓存层级(推荐)

不在拦截器层面缓存原始HTTP响应,而是在业务服务中缓存处理后的数据。既避免修改只读对象的问题,也让缓存逻辑更贴合业务需求:

// 在业务服务中添加缓存
private seriesWithGenresCache: Serie[] | null = null;

getSeriesWithGenres() {
    if (this.seriesWithGenresCache) {
      return of(this.seriesWithGenresCache);
    }

    const series$ = this.getSeries()
    const genres$ = this.getGenres()

    return forkJoin([series$, genres$]).pipe(
      map(([series, genres]) => {
        const processedSeries = series.map((serie: Serie) => {
          return {
            ...serie,
            genre_ids: serie.genre_ids.map(genreId => {
              return genres.find((genre: Genre) => genre.id === genreId)?.name
            })
          }
        });
        // 缓存处理后的数据
        this.seriesWithGenresCache = processedSeries;
        return processedSeries;
      })
    )
}

内容的提问来源于stack exchange,提问作者nhussein11

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.13 19:35:19