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

Angular中Bootstrap加载Spinner无法自动隐藏的问题排查

问题排查与优化方案

问题背景与代码实现

父组件中定义了@Input() alkatreszList$!: Observable<any[]>;,通过item对象传入子组件的@Input() item: any;输入属性:

<div class="container-fluid">
  <div class="row mb-3">
    <ng-container *ngFor="let item of filteredAlkatreszek; trackBy: trackByItemId">
      <div class="column">
        <app-carousel [item]="item"></app-carousel>
      </div>
    </ng-container>
  </div>
</div>
</ng-container>

子组件轮播实现了图片懒加载与Bootstrap Spinner加载状态:

<ng-container *ngIf="img.trim() !== ''">
  <img [defaultImage]="'https://localhost:7094/images/placeholder.png'"
       [lazyLoad]="'https://localhost:7094/images/' + img" style="height: 250px"
       [debug]="true"
       (onStateChange)="lazyLoadSpinner(i, $event)"
       (click)="selectItem(item)"/>
  <div class="progress-container" [ngClass]="{ 'd-none': isItemLoaded(i) }">
    <div class="spinner-overlay"></div>
    <div class="spinner-container">
      <div class="spinner-border text-light" role="status">
        <span class="visually-hidden">Loading...</span>
      </div>
    </div>
  </div>
</ng-container> 

相关变量与函数:

activeIndexModal = 0;
activeIndexCarousel = 0;  
loadedCounts: number[] = [];

lazyLoadSpinner(index: number, event: StateChange) {
    if (!this.loadedCounts[index]) {
      this.loadedCounts[index] = 0;
    }
  
    switch (event.reason) {
      case 'start-loading':
        if (this.loadedCounts[index] === 0) {
          // 开始加载新项
          this.loadedCounts[index] = -1; // 设为-1标记当前图片正在加载
        }
        break;
  
      case 'loading-succeeded':
        this.loadedCounts[index]++; // 增加当前项的加载完成计数
        if (this.loadedCounts[index] >= this.item.kepek.split(';').length) {
          // 当前项所有图片加载完成
          this.loadedCounts[index] = 0; // 重置计数用于下一项
        }
        break;
    }
  }

isItemLoaded(index: number): boolean {
    console.log("isItemLoaded() called");
    return this.loadedCounts[index] === 0;
  } 

子组件CSS样式:

.carousel-item {
  height: 400px !important;
  width: 100% !important;
  margin-bottom: var(--bs-gutter) !important;
}

.carousel-item.reactive img {
  object-fit: cover !important;
  height: 100% !important;
  width: 100% !important;
  transition: transform 0.2s;
}

.carousel-item.reactive img.hovered {
  transition: all 0.3s ease-in-out;
  transform: scale(1.1);
  z-index: 1;
  cursor: pointer;
}

.carousel-item.reactive:hover img {
  transform: scale(1.1);
}

.align-center {
  left: 50%;
  transform: translateX(-50%);
}

.carousel-control-next {
  width: 5% !important;
  right: 50px !important;
}

.carousel-control-prev {
  width: 5% !important;
  left: 50px !important;
}

.carousel-control {
  transition: .05s ease;
}

.modal {
  --bs-modal-border-radious: 10px !important;
  --bs-modal-padding: 5px !important;
  --bs-modal-border-color: darken(&primary, 10%) !important;
}

.modal-xl {
  --bs-modal-width: 1024px;
}

.modal-dialog .carousel-item{
  height: 100% !important;
}

.modal-dialog .carousel-item img {
  width: 1024px !important;
  height: 768px !important;
}

.progress-container {
  position: relative;
}

.spinner-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 9999;
}

.spinner-container {
  position: absolute;
  left: 50%;
  transform: translate(-50%, -600%);
}

@media (max-width: 767px) {
  .carousel-caption {
    width: auto !important;
    max-width: 100% !important;
    text-align: center !important;
    display: block !important;
  }
}

子组件完整模板:

<div id="imageCarouselCategoryPage" class="carousel slide">
  <div *ngIf="item.kepek.split(';').length > 1" class="carousel-indicators">
    <button
      type="button"
      [attr.data-bs-target]="'#imageCarouselCategoryPage'"
      [attr.data-bs-slide-to]="i"
      [class.active]="i === activeIndexCarousel"
      *ngFor="let img of item.kepek.split(';'); let i = index"
      [attr.aria-label]="'Slide ' + (i + 1)"
      (click)="changeActiveIndex(i, 'carousel')">
    </button>
  </div>
  <div class="carousel-inner">
    <div class="carousel-item reactive" [class.active]="i === activeIndexCarousel" *ngFor="let img of item.kepek.split(';'); let i = index">
      <ng-container *ngIf="img.trim() !== ''">
        <img [defaultImage]="'https://localhost:7094/images/placeholder.png'"
             [lazyLoad]="'https://localhost:7094/images/' + img" style="height: 250px"
             [debug]="true"
             (onStateChange)="lazyLoadSpinner(i, $event)"
             (click)="selectItem(item)"/>
        <div class="progress-container" [ngClass]="{ 'd-none': isItemLoaded(i) }">
          <div class="spinner-overlay"></div>
          <div class="spinner-container">
            <div class="spinner-border text-light" role="status">
              <span class="visually-hidden">Loading...</span>
            </div>
          </div>
        </div>
      </ng-container>
      <ng-container *ngIf="img.trim() === '' || !img">
        <img src="https://localhost:7094/images/placeholder.png" style="height: 250px; opacity: 25%;"/>
        <div class="carousel-caption d-none d-md-block" style="color: black;">
          <h5>{{ item.nev }}</h5>
          <p>{{ item.ar }} Ft</p>
        </div>
      </ng-container>
      <div class="carousel-caption d-none d-md-block" *ngIf="img.trim() !== '' || img">
        <h5>{{ item.nev }}</h5>
        <p>{{ item.ar }} Ft</p>
      </div>
    </div>
  </div>
</div>

问题描述

无论子组件使用OnPush还是默认变更检测策略,isItemLoaded()返回true(图片加载完成)后,必须与轮播或页面其他元素交互,Spinner才会隐藏。

问题排查与优化方案

核心问题分析

  1. 变更检测未触发:懒加载图片的onStateChange事件可能运行在Angular变更检测周期之外,导致Angular无法感知loadedCounts数组的变化,直到用户交互触发变更检测。
  2. 数组变更检测盲区:直接修改数组元素(如this.loadedCounts[index] = -1)属于数组内部变更,Angular的默认变更检测对这种操作的感知能力弱,尤其是使用OnPush策略时。

修复方案

方案1:手动触发变更检测

在lazyLoadSpinner函数中,修改loadedCounts后手动调用变更检测:

import { ChangeDetectorRef } from '@angular/core';

constructor(private cdr: ChangeDetectorRef) {}

lazyLoadSpinner(index: number, event: StateChange) {
    if (!this.loadedCounts[index]) {
      this.loadedCounts[index] = 0;
    }
  
    switch (event.reason) {
      case 'start-loading':
        if (this.loadedCounts[index] === 0) {
          this.loadedCounts[index] = -1;
        }
        break;
  
      case 'loading-succeeded':
        this.loadedCounts[index]++;
        if (this.loadedCounts[index] >= this.item.kepek.split(';').length) {
          this.loadedCounts[index] = 0;
        }
        break;
    }
    // 手动触发变更检测
    this.cdr.detectChanges();
  }

方案2:使用不可变数据更新数组

避免直接修改数组元素,创建新数组触发变更检测:

lazyLoadSpinner(index: number, event: StateChange) {
    // 初始化数组(如果未定义)
    if (!this.loadedCounts[index]) {
      this.loadedCounts = [...this.loadedCounts, 0];
    }
  
    switch (event.reason) {
      case 'start-loading':
        if (this.loadedCounts[index] === 0) {
          this.loadedCounts = this.loadedCounts.map((val, idx) => idx === index ? -1 : val);
        }
        break;
  
      case 'loading-succeeded':
        const newCount = this.loadedCounts[index] + 1;
        const finalCount = newCount >= this.item.kepek.split(';').length ? 0 : newCount;
        this.loadedCounts = this.loadedCounts.map((val, idx) => idx === index ? finalCount : val);
        break;
    }
  }

通过创建新数组替换原数组,让Angular感知到数据变化,自动触发变更检测。

方案3:改用BehaviorSubject管理加载状态

将loadedCounts替换为BehaviorSubject<number[]>,通过可观察对象订阅触发视图更新:

import { BehaviorSubject } from 'rxjs';

loadedCounts$ = new BehaviorSubject<number[]>([]);

lazyLoadSpinner(index: number, event: StateChange) {
    const currentCounts = this.loadedCounts$.value;
    let updatedCounts = [...currentCounts];
    
    if (!updatedCounts[index]) {
      updatedCounts[index] = 0;
    }
  
    switch (event.reason) {
      case 'start-loading':
        if (updatedCounts[index] === 0) {
          updatedCounts[index] = -1;
        }
        break;
  
      case 'loading-succeeded':
        updatedCounts[index]++;
        if (updatedCounts[index] >= this.item.kepek.split(';').length) {
          updatedCounts[index] = 0;
        }
        break;
    }
    
    this.loadedCounts$.next(updatedCounts);
  }

模板中使用async管道订阅:

<div class="progress-container" [ngClass]="{ 'd-none': (loadedCounts$ | async)[i] === 0 }">

这种方式符合Angular响应式编程理念,尤其适合OnPush变更检测策略。

额外优化建议

  1. 避免重复分割字符串:item.kepek.split(';')在模板和组件中多次调用,建议在组件初始化时缓存分割后的数组:
imageUrls: string[] = [];

ngOnInit() {
  this.imageUrls = this.item.kepek?.split(';') || [];
}

模板中直接使用imageUrls替代item.kepek.split(';'),提升性能。

  1. Spinner定位修复:当前Spinner的transform: translate(-50%, -600%)会导致Spinner偏移到视图外,建议修改为:
.spinner-container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

确保Spinner显示在图片中心位置。

内容的提问来源于stack exchange,提问作者Marschall Gábor

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.16 15:12:33