前端性能优化之组件销毁自动取消订阅

892 阅读6分钟

📢 DevUI团队重磅推出MateChat,前端智能化场景AI解决方案,为你的项目快速添加一个智能化助手,目前已在华为内部及外部多个服务应用落地实践,欢迎前来体验~

源码:gitcode.com/DevCloudFE/…(欢迎star~)

官网:matechat.gitcode.com

前端框架提供的内存占用优化

感谢我们社区贡献者Aubur22提供的优质文章!期待大家点在收藏~

在 JS 开发中,很多时候我们不需要关注内存占用情况。

ScreenShot_20250109142813.PNG

比如,Vue 或者 React 中,监听事件后,组件销毁时会自动取消监听,事件监听的内存占用被自动释放,如上图左边的情况。

// Vue
export default defineComponent({
  name: 'Button',
  setup() {
    const count = ref(0);
    return () => (
    // 组件销毁时自动取消监听 click
      <button onClick={() => count.value++}>count: { count.value }</button>
    );
  }
})

// React
export default function Button() {
  const [count, setCount] = useState(0);
  return (
    // 组件销毁时自动取消监听 click
    <button onClick={() => setCount(count + 1)}>count: { count }</button>
  );
}

虽然前端框架帮助我们做了很多事情,减少了对内存占用的关注。但是框架文档仍会提醒我们──在组件销毁时应及时清理不需要的数据。

如:组件中渲染 ECharts 图后,要在组件销毁阶段销毁图;EventBus/EventEmitter 订阅事件后,要在销毁时取消订阅;原生 JS 监听事件后,要及时取消监听;setInterval 及时清理等等。但手动完成这些事情往往意味着重复、遗漏、不够可靠

为什么需要自动取消订阅

本文将以 Angular 为例,介绍“组件销毁时自动取消订阅”的一种实现思路,其他框架也可参考同样的思路。

Angular 的状态管理和响应式模型非常依赖 RxJS,尤其是在 Angular Signals 普及开来之前。

在 RxJS 中订阅 Observable 之后,如果无需全运行时生效,则需要在适当时机取消订阅。若未及时取消订阅,会导致各种各样的问题:内存占用高、执行上下文变化导致程序执行异常等

ScreenShot_20250109143036.PNG

在组件中,我们通常会选择在组件销毁的时候手动取消订阅。手动取消订阅,1 个直接取消不麻烦,2 个以上通过数组批量取消也还行。

@Component({ selector: 'app-test', template: '' })
export class TestComponent {
  // 💡单个订阅
  scrollSubscription: Subscription;
  // 💡批量订阅
  subscriptionList: Subscription[];

  ngAfterViewInit() {
    this.scrollSubscription = fromEvent(document.documentElement, 'scroll').subscribe(console.log);

    this.subscriptionList.push(
      fromEvent(document, 'click').subscribe(console.log),
      fromEvent(document, 'mousemove').subscribe(console.log),
    );

    // 💡执行后自动结束,无需取消订阅
    http.get('/api/user/devui').subscribe(console.log)
  }

  ngOnDestroy() {
    this.scrollSubscription.unsubscribe();

    this.subscriptionList.forEach(item => item.unsubscribe());
  }
}

这时,同事引入了一种新的取消订阅形式。完成 1 件事情,就有了 3 种方式,可读性困难度增加

@Component({ selector: 'app-test', template: '' })
export class TestComponent {
  // 💡takeUntil 集中取消订阅
  destroySubject = new Subject<void>();

  ngAfterViewInit() {
    // 省略单个订阅、批量订阅 ...

    fromEvent(document, 'keydown').pipe(takeUntil(this.destroySubject)).subscribe(console.log);
  }

  ngOnDestroy() {
    this.destroySubject.next();
    this.destroySubject.complete();
  }
}

在多人协作的情况下,一个组件中多种多样的订阅及其取消方式越来越复杂。甚至,可能有些时候忘记取消订阅,程序不可控程度增加了。

而且我们还有很多别的组件,还会把上面各种订阅、取消订阅方式一遍遍重复地写。每次发现新组件中没有 destroySubject,还得手动创建一个变量,写 2 行销毁方法,真费劲。

太麻烦了,我们需要一种新的方式,来简化这样一件事 “组件销毁时,自动取消订阅”

如何实现自动销毁

基于上述 3 种订阅取消方式,自动销毁整体思路如下:

  1. 自动获取单个订阅,遍历组件 Subscription 类型属性。浅层遍历组件属性对性能影响可控。
  2. 自动获取指定的批量订阅,指定组件某个 Subscription[] 类型属性名。涉及深层遍历组件属性,对性能影响较大,此处直接指定一个属性名即可。
  3. 自动生成集中取消订阅所需要的 Subject,提供 RxJS 操作符自动关联 SubjecttakeUntil
  4. 扩展组件的 ngOnDestroy 原型方法,自动获取前 3 步的订阅,并取消订阅。

image.png

代码实现

○ 使用方式

// AutoDestroy 自动扩展了组件的 ngOnDestroy 方法
// 配合 checkProperties 属性,选择性开启是否在组件销毁时自动取消单个订阅,默认 true
// 配合 arrayName 属性,在组件销毁时,遍历 arrayName 指定的属性,批量取消订阅,默认空
// 自动执行结束 takeUntil(Subject) 中的 Subject
@AutoDestroy({ checkProperties: true, arrayName: 'subscriptionList' })
@Component({ selector: 'app-test', template: '' })
export class TestComponent {
  // 💡单个订阅
  scrollSubscription: Subscription;
  // 💡批量订阅
  subscriptionList: Subscription[];

  ngAfterViewInit() {
    this.scrollSubscription = fromEvent(document.documentElement, 'scroll').subscribe(console.log);

    this.subscriptionList.push(
      fromEvent(document, 'click').subscribe(console.log),
      fromEvent(document, 'mousemove').subscribe(console.log),
    );

    // 💡配合 autoDestroyPipe 自动添加 takeUntil(Subject) 的逻辑,注意 this 需为当前组件实例
    fromEvent(document, 'keydown').pipe(autoDestroyPipe(this)).subscribe(console.log);
  }

  // 💡ngOnDestroy 可直接移除,由 AutoDestroy 接管。特殊情况可保留,以处理其他销毁逻辑。
}

最佳实践

  1. 组件上方添加 @AutoDestroy(),不需要传参,使用默认配置
  2. 订阅中添加操作符 autoDestroyPipe(this),不使用单个订阅、批量订阅
  3. 一次性执行的订阅,不确定是否会自动结束时,添加操作符 take(1),无需使用本文的自动取消订阅方案

○ 实现

import { Observable, Subject, Subscription, takeUntil } from 'rxjs';

type SamePipeFunc = <SourceValue>(source: Observable<SourceValue>) => Observable<SourceValue>;

interface AutoDestroyOption {
  checkProperties?: boolean;
  arrayName?: string;
  excludeList?: string[];
}

const autoDestroyKey = {
  destroyMethodName: 'ngOnDestroy',
  active: Symbol('autoDestroy.active'), // 标识当前组件是否启用了 AutoDestroy
  subject: Symbol('autoDestroy.subject'), // 存储组件中用于 takeUntil 的 Subject
};

const unsubscribe = (subscription: Subscription) => {
  if (subscription instanceof Subscription) {
    subscription.unsubscribe();
  }
};

/**
 * 自动销毁时,添加的销毁内容
 */
const extendedDestroy = (instance: any, option: AutoDestroyOption) => {
  if (instance[autoDestroyKey.subject]) {
    const subject = instance[autoDestroyKey.subject] as Subject<void>;
    subject.next();
    subject.complete();
    instance[autoDestroyKey.subject] = null;
  }

  if (option.arrayName) {
    const list = (instance[option.arrayName] ?? []) as Subscription[];
    list.forEach(item => unsubscribe(item));
  }

  // 默认检查单个订阅
  if (option.checkProperties !== false) {
    const excludeList = new Set(option.excludeList ?? []);
    Object.keys(instance).forEach(key => {
      if (excludeList.has(key)) {
        return;
      }
      const value = instance[key];
      unsubscribe(value);
    });
  }
};

/**
 * 类装饰器,装饰于组件 class 上。可在组件销毁时,统一取消订阅
 * 注意区分 target(组件 class)和 this(组件实例,等同于 instance)
 */
export const AutoDestroy = (option: AutoDestroyOption = {}) => {
  return (target: any) => {
    const _destroy = target.prototype[autoDestroyKey.destroyMethodName];
    target.prototype[autoDestroyKey.destroyMethodName] = function (this: any, ...args: any[]) {
      if (typeof _destroy === 'function') {
        _destroy.apply(this, args);
      }
      extendedDestroy(this, option);
    };
    target.prototype[autoDestroyKey.active] = true;
  };
};

/**
 * 装饰了 AutoDestroy 的组件中,添加 autoDestroyPipe 操作符的 Observable,可在组件销毁时自动走到完成状态。
 * @example
 * // this 为组件实例,
 * from(document, 'click').pipe(autoDestroyPipe(this)).subscribe(console.log);
 */
export const autoDestroyPipe = (instance: any): SamePipeFunc => {
  if (!instance[autoDestroyKey.subject]) {
    instance[autoDestroyKey.subject] = new Subject<void>();
  }
  return source => {
    const subject = instance[autoDestroyKey.subject];
    return source.pipe(takeUntil(subject));
  };
};

参考

  • until-destroyAutoDestroy 实现过程中,部分采用了 until-destroy 的配置 api,便于互相替换。until-destroy 更全面,AutoDestroy 更轻量。

🔥 加入我们

感谢我们社区贡献者Aubur22提供的优质文章!内容觉得不错的话大家也给我们的小伙伴点点关注哦~

DevUI是面向企业中后台产品的开源前端解决方案,其设计价值观基于高效、开放、可信、乐趣四种自然与人文相结合的理念,是一款企业级开箱即用的产品。

MateChat仓库地址:gitcode.com/DevCloudFE/…

MateChat官网:matechat.gitcode.com

DevUI社区仓库地址: gitcode.com/DevCloudFE

DevUI Design 官网: devui.design/home

如果你对我们的开源项目感兴趣,并希望参与共建,欢迎加入我们的开源社区,关注DevUI微信公众号:DevUI 。我们还为大家准备了特有的开源活动 ✨DevUI 点亮计划启动~ 快来参与吧~