26、屏幕共享Angular组件:Service封装、依赖注入、ChangeDetection优化

这一章,我们来聊聊Angular里的屏幕共享组件怎么写才够“专业”。

说实话,很多同学写Angular组件,功能是能跑,但一遇到复杂场景——比如屏幕共享这种高频触发变更的——就开始卡顿、内存泄漏、甚至直接崩溃。我早期就踩过这个坑,后来才慢慢摸索出一套比较稳的写法。

核心就三件事:Service封装、依赖注入、ChangeDetection优化。咱们一个一个拆开讲。

为什么要把屏幕共享逻辑封装到Service里?

你想想看,屏幕共享的逻辑其实挺重的:获取媒体流、监听设备变化、处理权限、管理生命周期……如果全写在组件里,那组件会变得又臭又硬,难以测试,更难以复用。

我个人习惯是:组件只负责UI展示和用户交互,所有业务逻辑都交给Service。这样组件就变成了一个“瘦壳”,清爽很多。

核心原则: 组件 = 视图 + 事件绑定;Service = 状态 + 逻辑 + 副作用管理。

Service封装:把屏幕共享变成可注入的服务

我们先写一个基础的 ScreenShareService。它负责获取屏幕流、停止共享、以及暴露流的状态。

// screen-share.service.ts
import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class ScreenShareService {
  private streamSubject = new BehaviorSubject<MediaStream | null>(null);
  public stream$: Observable<MediaStream | null> = this.streamSubject.asObservable();

  private isSharingSubject = new BehaviorSubject<boolean>(false);
  public isSharing$: Observable<boolean> = this.isSharingSubject.asObservable();

  constructor(private ngZone: NgZone) {}

  async startScreenShare(): Promise<MediaStream> {
    try {
      const stream = await navigator.mediaDevices.getDisplayMedia({
        video: true,
        audio: true
      });

      // 监听用户停止共享(浏览器自带按钮)
      stream.getVideoTracks()[0].onended = () => {
        this.stopScreenShare();
      };

      this.ngZone.run(() => {
        this.streamSubject.next(stream);
        this.isSharingSubject.next(true);
      });

      return stream;
    } catch (err) {
      console.error('屏幕共享启动失败:', err);
      throw err;
    }
  }

  stopScreenShare(): void {
    const stream = this.streamSubject.getValue();
    if (stream) {
      stream.getTracks().forEach(track => track.stop());
    }
    this.ngZone.run(() => {
      this.streamSubject.next(null);
      this.isSharingSubject.next(false);
    });
  }
}

这里有个细节:onended 回调是在浏览器事件循环里触发的,不在Angular的Zone内。所以必须用 ngZone.run() 把状态更新包进去,否则视图不会更新。嗯,这个坑我当年调了一下午才找到原因。

依赖注入:让组件和Service解耦

有了Service,组件里只需要注入它,然后调用方法即可。依赖注入的好处是:你可以随时替换实现,比如在测试时注入一个Mock Service。

// screen-share.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ScreenShareService } from './screen-share.service';
import { Subscription } from 'rxjs';

@Component({
  selector: 'app-screen-share',
  templateUrl: './screen-share.component.html',
  styleUrls: ['./screen-share.component.css']
})
export class ScreenShareComponent implements OnInit, OnDestroy {
  localStream: MediaStream | null = null;
  isSharing = false;
  private subs: Subscription[] = [];

  constructor(private screenShareService: ScreenShareService) {}

  ngOnInit(): void {
    this.subs.push(
      this.screenShareService.stream$.subscribe(stream => {
        this.localStream = stream;
      })
    );

    this.subs.push(
      this.screenShareService.isSharing$.subscribe(sharing => {
        this.isSharing = sharing;
      })
    );
  }

  async onStartShare(): Promise<void> {
    try {
      await this.screenShareService.startScreenShare();
    } catch (err) {
      alert('屏幕共享启动失败,请检查权限设置。');
    }
  }

  onStopShare(): void {
    this.screenShareService.stopScreenShare();
  }

  ngOnDestroy(): void {
    this.subs.forEach(sub => sub.unsubscribe());
    this.screenShareService.stopScreenShare();
  }
}
小技巧:ngOnDestroy 里一定要取消订阅并停止共享。否则组件销毁了,屏幕流还在跑,用户会一脸懵。

ChangeDetection优化:别让Angular累死

屏幕共享的流数据变化非常频繁——每一帧都在变。如果Angular的变更检测机制每次都去检查整个组件树,性能会急剧下降。

我建议的做法是:把视频渲染交给原生元素,绕过Angular的变更检测

方案一:OnPush + 手动触发

把组件的 changeDetection 设为 ChangeDetectionStrategy.OnPush,然后只在真正需要更新UI的时候手动触发检测。

// screen-share.component.ts (优化版)
import { Component, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';

@Component({
  selector: 'app-screen-share',
  templateUrl: './screen-share.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class ScreenShareComponent {
  constructor(
    private screenShareService: ScreenShareService,
    private cdr: ChangeDetectorRef
  ) {}

  ngOnInit(): void {
    this.subs.push(
      this.screenShareService.stream$.subscribe(stream => {
        this.localStream = stream;
        this.cdr.markForCheck(); // 手动标记检查
      })
    );
  }
}

这样,Angular只会在 stream$ 发出新值时检查这个组件,不会波及整个组件树。

方案二:直接操作DOM(更极致)

如果你对性能有极致要求,可以完全绕过Angular,直接用原生API把流绑定到 <video> 元素上。

// 在组件中
@ViewChild('localVideo', { static: true }) videoRef!: ElementRef<HTMLVideoElement>;

ngOnInit(): void {
  this.subs.push(
    this.screenShareService.stream$.subscribe(stream => {
      if (stream) {
        this.videoRef.nativeElement.srcObject = stream;
        this.videoRef.nativeElement.play();
      } else {
        this.videoRef.nativeElement.srcObject = null;
      }
    })
  );
}

这种方式下,视频流的每一帧都不会触发Angular的变更检测。说白了,视频渲染完全交给浏览器底层去处理,Angular只负责管理“是否在共享”这个布尔状态。

注意: 直接操作DOM虽然性能好,但会失去Angular的模板绑定能力。如果你需要在视频上叠加UI元素(比如水印、标注),还是得用OnPush方案。

知识结构图

下面这张图概括了本章的核心逻辑:

屏幕共享Angular组件核心架构 ScreenShareComponent 视图 + 事件绑定 OnPush 变更检测 手动 markForCheck ScreenShareService getDisplayMedia() BehaviorSubject 状态管理 ngZone.run() 保Zone 浏览器API MediaStream onended 事件 权限管理 ChangeDetection 优化策略 • 方案一:OnPush + markForCheck —— 适合需要Angular模板绑定的场景 • 方案二:直接操作DOM (srcObject) —— 极致性能,适合纯视频渲染 • 核心思想:减少变更检测范围,把高频变化隔离在Angular Zone之外

避坑指南

  • 不要忘记取消订阅:我曾经在线上环境发现内存只增不减,查了半天发现是 stream$ 的订阅没在 ngOnDestroy 里清理。屏幕共享流一旦开启,订阅会一直持有引用,导致组件无法被垃圾回收。
  • onended 回调的Zone问题:浏览器触发的 onended 不在Angular Zone内,必须用 ngZone.run() 包裹状态更新。否则你会发现用户点了“停止共享”,UI却纹丝不动。
  • 不要频繁调用 markForCheck:如果每帧都调,OnPush 也救不了你。只在你真正需要更新UI的时候(比如共享状态变化)才调用。
  • 测试时记得Mock getUserMedia:屏幕共享API在无头浏览器里不支持,写单元测试时一定要用 jasmine.createSpyObj 把Service mock掉。

总结

这一章的核心就三句话:

  1. Service封装:把屏幕共享逻辑抽离到Service里,组件只负责视图。
  2. 依赖注入:通过Angular的DI机制,让组件和Service解耦,方便测试和替换。
  3. ChangeDetection优化:用OnPush策略或直接操作DOM,避免高频流数据拖垮性能。

做到这三点,你的屏幕共享组件基本就稳了。下一章我们会聊更进阶的话题——如何在共享过程中动态切换屏幕窗口,以及处理多显示器场景。嗯,那个坑更多,到时候再细说。


公众号:蓝海资料掘金营,微信deep3321