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

Angular 15无后端场景下批量API请求的最优处理方案

Angular 15 结合TMDB API 实现剧集筛选与无限滚动最优方案

针对你遇到的TMDB API筛选限制问题,结合Angular 15的特性,以下是一套高效的实现方案,核心思路是按需分页加载+并发请求控制+本地缓存,配合MatCard展示与无限滚动提升用户体验:

一、核心API调用策略优化

  • 按需分页触发:利用无限滚动仅在用户滚动到底部时加载下一页的discover结果,避免一次性拉取3k页的巨大数据量
  • 并发请求限流:对单页20个剧集ID,按批次(比如每5个一组)调用tv-details接口,避免触发TMDB的API限流规则(默认40次/10秒)
  • 本地缓存复用:用Map存储已获取的剧集详情,后续相同ID直接读取缓存,减少重复请求

二、基于Angular CDK的无限滚动实现

首先确保项目已引入Angular CDK的滚动模块及Angular Material组件:

// 你的模块文件(如ShowFilterModule.ts)
import { ScrollingModule } from '@angular/cdk/scrolling';
import { MatCardModule } from '@angular/material/card';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSkeletonModule } from '@angular/material/skeleton';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';

@NgModule({
  imports: [
    // ...其他依赖模块
    ScrollingModule,
    MatCardModule,
    MatProgressBarModule,
    MatSkeletonModule,
    MatProgressSpinnerModule
  ]
})
export class ShowFilterModule {}

模板中结合cdkInfiniteScroll指令与MatCard实现列表展示,同时加入加载状态的骨架屏:

<div class="show-list-container"
     cdkInfiniteScroll
     [cdkInfiniteScrollDisabled]="isLoading || hasNoMoreData"
     (cdkInfiniteScroll)="loadNextPage()">
  <mat-card 
    *ngFor="let show of filteredShows$ | async; trackBy: trackShowId"
    class="show-card">
    <!-- 加载中骨架屏 -->
    <ng-container *ngIf="show.isLoading; else showContent">
      <mat-card-content class="skeleton-wrapper">
        <mat-skeleton height="60px"></mat-skeleton>
        <mat-skeleton width="80%" height="20px" style="margin: 8px 0;"></mat-skeleton>
        <mat-skeleton width="60%" height="16px"></mat-skeleton>
      </mat-card-content>
    </ng-container>
    <!-- 剧集详情内容 -->
    <ng-template #showContent>
      <mat-card-title>{{show.details.name}}</mat-card-title>
      <mat-card-subtitle>
        第{{show.details.number_of_seasons}}季 · {{show.details.number_of_episodes}}集
        <span class="status-tag">{{show.details.status}}</span>
      </mat-card-subtitle>
      <mat-card-content>
        <p>{{show.details.overview || '暂无剧集简介'}}</p>
      </mat-card-content>
    </ng-template>
  </mat-card>

  <!-- 加载更多提示 -->
  <div *ngIf="isLoading" class="loading-footer">
    <mat-progress-spinner diameter="30" mode="indeterminate"></mat-progress-spinner>
  </div>
</div>

三、组件核心逻辑实现

import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of, forkJoin, concatMap } from 'rxjs';
import { map, tap, catchError, retry } from 'rxjs/operators';

// 定义带加载状态的剧集结构
interface ShowWithLoading {
  id: number;
  details?: any; // 对应TMDB TV Details返回结构
  isLoading: boolean;
}

@Component({
  selector: 'app-show-filter',
  templateUrl: './show-filter.component.html',
  styleUrls: ['./show-filter.component.css']
})
export class ShowFilterComponent implements OnInit {
  filteredShows$: Observable<ShowWithLoading[]>;
  private showsSubject = new BehaviorSubject<ShowWithLoading[]>([]);
  
  currentPage = 1;
  totalPages = 1;
  isLoading = false;
  hasNoMoreData = false;
  private showCache = new Map<number, any>();
  // 筛选条件(可根据用户输入动态调整)
  private filterConditions = {
    episodeCount: 8,
    status: 'ended'
  };

  constructor(private http: HttpClient) {
    this.filteredShows$ = this.showsSubject.asObservable();
  }

  ngOnInit(): void {
    // 初始化加载第一页
    this.loadNextPage();
  }

  loadNextPage(): void {
    if (this.isLoading || this.currentPage > this.totalPages) return;
    
    this.isLoading = true;
    // 1. 调用discover接口获取当前页剧集ID列表
    this.http.get<any>(`https://api.themoviedb.org/3/discover/tv`, {
      params: {
        api_key: 'YOUR_TMDB_API_KEY', // 替换为你的API密钥
        page: this.currentPage.toString(),
        status: this.filterConditions.status
      }
    }).pipe(
      tap(res => {
        this.totalPages = res.total_pages;
        this.currentPage++;
      }),
      // 转换为带加载状态的临时条目
      map(res => res.results.map((item: any) => ({
        id: item.id,
        isLoading: true
      } as ShowWithLoading))),
      // 追加到现有列表
      tap(temporaryItems => {
        const currentItems = this.showsSubject.value;
        this.showsSubject.next([...currentItems, ...temporaryItems]);
      }),
      // 分批次获取剧集详情,控制并发数
      concatMap(temporaryItems => {
        const idChunks = this.chunkArray(temporaryItems.map(item => item.id), 5);
        return forkJoin(
          idChunks.map(chunk => forkJoin(
            chunk.map(id => this.getShowDetails(id))
          ))
        ).pipe(map(results => results.flat()));
      }),
      retry(2), // 临时错误重试2次
      catchError(err => {
        console.error('数据加载失败:', err);
        this.isLoading = false;
        return of([]);
      })
    ).subscribe(detailsList => {
      // 更新列表:保留符合筛选条件的剧集,移除不符合的
      const updatedShows = this.showsSubject.value.map(item => {
        const matchDetail = detailsList.find(d => d.id === item.id);
        if (!matchDetail) return item;

        this.showCache.set(item.id, matchDetail);
        // 检查是否满足集数条件
        if (matchDetail.number_of_episodes === this.filterConditions.episodeCount) {
          return { ...item, details: matchDetail, isLoading: false };
        } else {
          // 不符合条件的直接过滤掉
          return null;
        }
      }).filter(item => item !== null) as ShowWithLoading[];

      this.showsSubject.next(updatedShows);
      this.isLoading = false;
      this.hasNoMoreData = this.currentPage > this.totalPages;
    });
  }

  // 获取单部剧集详情,优先读缓存
  private getShowDetails(id: number): Observable<any> {
    if (this.showCache.has(id)) {
      return of(this.showCache.get(id));
    }
    return this.http.get<any>(`https://api.themoviedb.org/3/tv/${id}`, {
      params: { api_key: 'YOUR_TMDB_API_KEY' }
    }).pipe(
      tap(detail => this.showCache.set(id, detail))
    );
  }

  // 数组拆分工具函数:按指定大小拆分数组
  private chunkArray<T>(arr: T[], size: number): T[][] {
    const chunks = [];
    for (let i = 0; i < arr.length; i += size) {
      chunks.push(arr.slice(i, i + size));
    }
    return chunks;
  }

  // trackBy函数:提升列表渲染性能
  trackShowId(index: number, show: ShowWithLoading): number {
    return show.id;
  }

  // 当用户修改筛选条件时调用此方法重置状态
  resetFilter(newConditions: { episodeCount: number; status: string }): void {
    this.filterConditions = newConditions;
    this.currentPage = 1;
    this.totalPages = 1;
    this.hasNoMoreData = false;
    this.showCache.clear();
    this.showsSubject.next([]);
    this.loadNextPage();
  }
}

四、关键优化细节

  • 筛选条件动态适配:通过resetFilter方法,可在用户修改筛选条件时重置所有状态,重新加载数据
  • 性能优化:使用trackBy函数避免列表重复渲染,骨架屏提升加载过程的用户体验
  • 限流防护:分批次调用详情接口,配合retry处理临时网络错误,降低触发TMDB API限流的概率
  • 缓存复用:本地缓存已请求的剧集详情,重复筛选时无需再次调用接口

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 01:02:58