zone.js由入门到放弃之四——Angular对zone.js的应用

啸达同学的zone.js系列分享第四篇,新鲜出炉,终于等到了Angular对zone.js的使用

zone.js系列往期文章

NgZone

我在《zone.js由入门到放弃之一》中简述过zone.js和NgZone的关系,我说ngZone生于zone.js;长于Angular。在这里我再解释一下这句话的意思:首先zone.js维护了一个执行上下文栈,可以帮助开发者追踪异步任务、并通过生命周期勾子注入业务。NgZone实际上就是一个从root zone中fork出来的子zone。只不过这个子zone是专门为Angular量身定做的,并被当作一个可注入的服务被集成到Angular开发工具中。我见过有些文章中说Angular封装了zone.js从而构建出NgZone。其实这种说法是不准确的,个人理解,Angular其实并没有对zone.js的框架或是核心做任何改动,只是利用zone.js的执行上下文来监听异步事件,从而指导Angular在合适的时机进行变更检测

NgZone的前半生

本文的开始,我们先看下NgZone是在何时何处构造出来的:

首先,一个Angular的工程的入口文件是main.ts。在main.ts中,大家大多都会见到这么一句platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err));,我这里简单叙述一下这里到底执行了些啥。Angular是支持跨平台运行的,那么Angular在执行前需要确定当前工程到底是运行在哪一个平台下的:浏览器?服务端(SSR)?WebWorker?或是移动端?

platformBrowserDynamic()方法返回的实际就是一个平台对象PlatformRefPlatformRef中定义了如何引导启动一个Angular应用。在这里我多扯几句Angular实现化平台运行的原理。Angular工程在初始化的时候会注入很多基础服务,比如Renderer2、Compiler等等很多。这些服务其实都是一些抽象类,对外提供了统一的API,对内会屏蔽了不同平台之间的差异。当我们的Angular应用运行在不同平台时,Angular都会有一套相对应的实现逻辑;就像设计模式中的适配器一样,不同平台有不同平台的adapter。这也就是为什么,我们在浏览器时使用BrowserModule启动应用;而在SSR中使用AppServerModule启动应用。

那么在浏览器模式下,platformBrowserDynamic()返回的平台信息在application_ref.ts这个文件下。这个服务对外暴漏了一个bootstrapModuleFactory方法,当我们通过bootstrapModule启动Angular应用的时候,bootstrapModule最终会调用到bootstrapModuleFactory。而从这个bootstrapModuleFactory开始,我们将第一次在Angular中看到NgZone的身影。

@Injectable({providedIn: 'platform'})
export class PlatformRef {
  ...
  constructor(private _injector: Injector) {}

  bootstrapModuleFactory<M>(moduleFactory: NgModuleFactory<M>, options?: BootstrapOptions):
      Promise<NgModuleRef<M>> { ... }
  ...
}

bootstrapModuleFactory何许人也

bootstrapModuleFactory中通过getZone方法构建了ngZone服务。getZone方法也比较简单,它会实例化一个NgZone服务。

function getNgZone(ngZoneToUse: NgZone|'zone.js'|'noop'|undefined, options: NgZoneOptions): NgZone {
  let ngZone: NgZone;

  // 留着后面讲
  if (ngZoneToUse === 'noop') {
    ngZone = new NoopNgZone();
  } else {
    ngZone = (ngZoneToUse === 'zone.js' ? undefined : ngZoneToUse) || new NgZone(options); 👈
  }
  return ngZone;
}

而下面就是我简化过后NgZone的构建逻辑,是不是一下子看到很多熟悉的勾子函数。正向前文说的,NgZone就是一个特殊的Zone,而帮助Angular进行变更检测的所有逻辑都集中在ZoneSpec中定义的这几个勾子中,了解了这些内容会对掌握Angular变更检测原理提供很大帮助。不过本期不会对这几个勾子进行详细讲解,下一篇文章,我会step by step地演示这其中的逻辑,对这块感兴趣的可以关注一下。

export class NgZone {
  constructor() {
    self._outer = self._inner = Zone.current;
    zone._inner = zone._inner.fork({
      name: 'angular',
      properties: <any>{ isAngularZone: true },
      onInvokeTask: (...): any => {
        ...
      },
      onInvoke: (...): any => {
        ...
      },
      onHasTask: (...): any => {
        ...
      },
      onHandleError: (...): any => {
        ...
      },
    });
  }
}

所以本期讲解到此结束…

1_df344eabd84c7d87140948ff6e68b941_589x500.webp@900-0-90-f.webp

哈哈,我当然不会这么敷衍的,其实到这里这次的NgZone分享才刚刚开始。NgZone有个非常有意思的属性叫做_outer,因为大家在使用Angular的时候很少会直接跟zone.js接触,而这个_outer,它也是一个zone的实例,它在Angular中的存在感要远比zone.js多的多。后文中,我们统一把_outer称之为OuterZone,而把_inner称之为InnerZone。

export class NgZone {
  constructor() {
    self._outer = self._inner = Zone.current; 👈
    zone._inner = zone._inner.fork({...});
  }
}

OuterZone

曾经有一个作者这么评价zone.js对Angular的贡献:

作为 Angular 开发者,我们每个人都欠 Zone.js 一顿饭:多亏了有 Zone 的协助,我们能够以魔术般的方式使用 Angular;事实上,大部分时候我们只是修改了一个属性,Angular 就会自动渲染组件,确保视图总是及时更新。非常酷!

2_999ca577e1d75832e259cdcbc4f640eb_499x342.webp@900-0-90-f.webp

话虽如此,但是如果我说OuterZone的出现就是为了让Angular可以摆脱zone.js的控制而运行,这会不会显得很打脸。Angular团队解释这么做是为了性能。因为zone.js会在初始化时将很多异步方法Patch了,从而可以监控到这些异步任务,并通知Angular在适当的时机进行变更检测。但是有的时候,我们有些业务并不需要触发变更检测,毕竟每进行一次变更检测在时间和空间上都是有消耗的。尤其是像拖拽、鼠标移动、滚动条这种事件,他们会在短时间被触发多次。如果每次事件触发都需要进行变更检测,那就太浪费了。所以,Angular团队以及zone.js的作者都开始想办法,让开发者的一些动作可以不受zone.js的“监管”。这里我总结了几种办法:

  • 使用noop代替zone.js,让Angular完全与zone.js脱离关系
  • 使用OnPush策略
  • 让zone.js停止对某些异步方法进行跟踪
  • 使用OuterZone

让Angular完全与zone.js脱离关系

这一点其实Angular团队已经写到官方指导中了,Angular团队同时也给出了代码案例说明了脱离zone.js后应该如何进行变更检测。这里我就不过多介绍这部分内容了,毕竟修改起来也就2行代码的事。

OnPush策略

由于上面脱离方式过于暴烈,Angular同时又提供了OnPush策略用来进行组件级的性能优化。其实按道理讲,OnPush策略其实跟zone.js并没有什么关系,放在这里只不过是想说明一下,这也是一种让代码“脱离”变更检测的方式(OnPush策略并不是完全脱离)。同时,Angular也建议在使用OnPush策略的时候,配合ChangeDetection一起使用,这样能让你在需要变更检测的时候也能恢复变更检测。对于OnPush策略的文献也很多,我这里也不做展开了,感兴趣的可以自己搜一下。

让zone.js停止对某些异步方法进行跟踪

我在《zone.js由入门到放弃之二》中介绍过如何让zone.js放弃对setTimeout进行Patch,当我设置了global.__Zone_disable_timers = true;后,setTimeout就不会被Patch了。诸如这样的配置有很多,需要的可以点击这里。👈

使用OuterZone

首先,我们明确一下ngZone的构造过程中生成了两个Zone,InnerZone是负责跟Angular配合进行变更检测的;而OuterZone实际就是Zone.current,它并不会参与Angular的变更检测。NgZone中定义了一个runOutsideAngular的方法,这个方法会调用OuterZone.run方法,让参数中的fn可以执行在OuterZone中。

export class NgZone {
  ...
  runOutsideAngular<T>(fn: (...args: any[]) => T): T {
    return (this as any as NgZonePrivate)._outer.run(fn);
  }
}

举个例子,假设你有一个setTimeout方法。当这个方法在Angular中执行时,由于zone.js对setTimeout进行过打包,所以zone.js会追踪setTimeout的各个执行阶段并触发对应的钩子函数。又由于InnerZone是rootZone的一个子Zone,同时InnerZone中设置了大量了的勾子函数,所以InnerZone也可以感知到setTimeout的执行过程,并在特定的情况下触发便变检测。在Angular中,大多的异步过程都是这么执行的。

当我们有一天不希望某个setTimeout方法再触发变更时,我们可以让这个setTimeout执行在runOutsideAngular中。此时,因为OuterZone没有设置任何勾子函数,也不会通知Angular应用进行变更检测。所以,runOutsideAngular实际上相当于给你提供一块世外桃源,让你可以“安静”地运行一些异步任务。

Show me your code

上面讲了这么多概念,下面我想用一个简单的性能优化案例来串一下今天所有的知识点。在本期示例中,我们要做一个自动登出的界面。界面每过5s会检查一次页面上是否有鼠标操作。如果有,则页面保持登录状态;如果没有,界面自动登出。在这里,我们仅对界面的登录、登出状态做简单处理——通过isLogined控制登录状态。

// app.component.html

<div class="hotspot">
  <h1>{{isLogined ? '欢迎来到自动登出系统!' : '期待您下次光临!'}}!</h1>
</div>

需求澄清

  • Origin:界面每5s进行一次检测
  • Origin:5s内有鼠标移动、鼠标滚轮事件触发,则界面保持登录态;反之,界面自动登出

V1版本

V1版本中,app组件在构造时启动定时器,定时器每过5s检查页面状态isDirtyisDirty === trye,则页面有鼠标事件触发;isDirty === false,则页面自动登出。鼠标事件通过@HostListener监听;同时通过ngDoCheck勾子,观测页面进行变更检测的频次。

// app.component.v1.ts

export class AppComponent {
  isLogined = true;
  timer: any;
  isDirty = false;

  constructor() {
    this.startTiming();
  }

  // 变更检测时会被触发
  ngDoCheck() {
    console.log('rendering...');
  }

  startTiming() {
    console.log('startTiming!');
    this.timer = setInterval(() => {
      // 当检测没到有鼠标事件触发过,则停止检测
      if (!this.isDirty) {
        this.stopTiming();
      }
      this.isDirty = false;
    }, 5000);
  }

  stopTiming() {
    clearInterval(this.timer);
    this.isLogined = false;
  }

  // 鼠标移动监听
  @HostListener('mousemove')
  mouseLisener() {
    this.isDirty = true;
    console.log('mousemoved');
  }

  // 鼠标滚轮监听
  @HostListener('window:scroll')
  onScrollEvent() {
    this.isDirty = true;
    console.log('scrollmoved');
  }

  ngOnDestroy(): void {
    this.timer ?? clearInterval(this.timer);
  }
}

通过Angular提供的DevTools可以方便地监控到Angular应用执行过程中的性能情况,当页面加载后,只要稍微动一动鼠标,Angular的变更检测就会疯狂执行(每一个小柱子代表一次CD)。

3_61db0d9858c7f92a616370df2a0c8920_2552x291.png@900-0-90-f.png

下图是界面中控制台的疯狂输出,这里每当界面上有鼠标滚轮或是鼠标移动事件发生后,都会引起Angular进行一次变更检测。

4_97a2978c8105aa6e329de873d03b1d15_209x549.png@900-0-90-f.png

V2版本

从V1版本的日志图中我们可以发现,每次scrollmoved执行过后都会紧跟一个rendering...打印。所以在V2版本中,我们先对需求做一些调整,我们删除对滚轮事件的监听。为了能尽量全地演示这些性能提升手段,V2版本中,我们通过zone.js中的屏蔽手段屏蔽对鼠标滚轮事件的检测。

  • Origin:界面每5s进行一次检测
  • Changed:5s内有鼠标移动、鼠标滚轮事件触发,则界面保持登录态;反之,界面自动登出

我在《zone.js由入门到放弃之二》中讲过对setTimeout方法的屏蔽方法,这里我也把zone.js提供的所有屏蔽API分享出来,大家可以按需使用。

👇👇👇

在Angular中屏蔽很简单,但是有坑。

STEP1

增加一个zone-flag.v2.ts文件(文件名随便取),内容就一行如下:

(window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll'];
STEP2

polyfills.ts文件中写入:

import './zone-flag.v2';
import 'zone.js/dist/zone';

切记,这里必须这么写,直接把(window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll'];写到pollyfills是不行,想问为啥,问就是变量提升的坑。👈

此时,重新运行Angular应用,这时你会发现,scrollmoved日志后面没有再紧跟rendering...日志了,这意味着鼠标的scroll事件已经不会触发变更检测了。

5_50907d24f767ba91f24bdb0942c3584c_301x119.png@900-0-90-f.png

既然鼠标滚轮事件已经移出我们的监听范围,则我们也可以修改一下app中的代码,将对scroll的监听移除。

// app.component.v2.ts
export class AppComponent {
  // 鼠标滚轮监听
  // @HostListener('window:scroll')
  // onScrollEvent() {
  //   this.isDirty = true;
  //   console.log('scrollmoved');
  // }
}

V3版本

V2中,我们已经通过zone.js让鼠标的scroll事件脱离了变更检测。接下来,我们使用ngZone的runOutsideAngular方法,让mousemove也脱离Angular的变更检测。V3的代码,分别注入了Renderer2ElementRefNgZone服务。通过runOutsideAngular注册监听可以让事件触发变更检测。同时,我们通过mouseMoveUnsub保存事件的注销方法,在界面登出后注销事件监听。

// app.component.v3.ts

export class AppComponent {
  isLogined = true;
  timer: any;
  isDirty = false;

  mouseMoveUnsub: any;

  constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone) {
    this.startTiming();
    // 在OutZone中注册监听事件,事件触发时不会引起变更检测
    this.zone.runOutsideAngular(() => {
      this.mouseMoveUnsub =
        this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
    });
  }

  // 变更检测时会被触发
  ngDoCheck() {
    console.log('rendering...');
  }

  startTiming() {
    console.log('startTiming!');
    this.timer = setInterval(() => {
      // 当检测没到有鼠标事件触发过,则停止检测
      if (!this.isDirty) {
        this.stopTiming();
      }
      this.isDirty = false;
    }, 5000);
  }

  stopTiming() {
    clearInterval(this.timer);
    this.isLogined = false;

    // 解除事件监听
    this.mouseMoveUnsub();
  }

  // 鼠标移动监听
  // @HostListener('mousemove')
  mouseLisener() {
    this.isDirty = true;
    console.log('mousemoved');
  }

  ngOnDestroy(): void {
    this.timer ?? clearInterval(this.timer);
  }
}

这是优化后的控制台,在保证功能不变的情况下,Console控制台也清净了不少。除了setInterval会间歇性地触发变更检测,其它的鼠标事件已经都不会触发变更检测了。

6_c44dabfb6f5faa46f03021356961e17d_925x209.png@900-0-90-f.png

接下来,我们把V3的性能profile拿出来对比看一下。在V3版本下,不管你在界面上如何操作鼠标,都不会触发变更检测了。从图上我们也能看出,Angular变更检测的周期基本上每隔5s才会触发一次,与setInterval的执行周期一致,这也是符合预期的。将V3的火焰图跟V1的对比一下你就会发现,此时变更检测的次数远小于之前。我大致看了一下,每次变更检测的耗时大概在0.1ms~0.6ms之间。这么看,同样的功能,性能之间的差异有着天壤之别!

7_4e1b36b0c8aa0c74c9ada6a937a72fce_2556x413.png@900-0-90-f.png

V4

最后一版我们再把最后一点小问题优化掉,从V3图上我们还能零星看到几次rendering...日志,之前说了,这是由于setInterval导致的。V4版本就是要把setInterval产生的变更检测也优化掉。

STEP1

在经过前面的优化学习之后,这里的处理对大家说应该十分好理解了。这里,我们同样通过runOutsideAngular方法处理setInterval的执行。

// app.component.v4.ts

export class AppComponent {
  // ...
  constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone, private cd: ChangeDetectorRef) {
    // 在OutZone中注册监听事件,事件触发时不会引起变更检测
    this.zone.runOutsideAngular(() => {
      // 进一步消除setInterval的变更检测
      this.startTiming();
      this.mouseMoveUnsub =
        this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
    });
  }
  // ...
}

但是,当我们把this.startTiming();放到runOutsideAngular后,我们发现如果5s没有对界面操作,界面也不会变成期待您下次光临!!。这里希望大家能先自己想想这是为什么,然后再往下看。


7_9c5d9bc021acf833845612ece36f0dae_750x500.webp@900-0-90-f.webp

STEP2

NgZone的本质是用来配合执行变更检测的,当我们使用runOutsideAngular后,回调函数的执行将会脱离变更检测。又由于在zone.js中,Zone的执行上下文是会传递的;当setInterval中的回调执行是,它依旧会在OutZone中执行。试想一下,当this.stopTiming();执行在OutZone中的时候,this.isLogined = false;根本不会引起变更检测,则UI也不会进行渲染。此时,你会发现,当你脱离变更检测的时候,双向绑定的魔力也会消失。

此时,我们就需要手动唤醒变更检测。这里唤醒变更的方式有多钟:

  • 通过NgZone.run方法可以让被执行方法回到InnerZone中执行,从而触发变更检测
  • 通过ChangeDetectorRef.detectChanges手动进行变更检测
// app.component.v4.ts

export class AppComponent {
  isLogined = true;
  timer: any;
  isDirty = false;

  mouseMoveUnsub: any;

  constructor(private render2: Renderer2, private elementRef: ElementRef, private zone: NgZone, private cd: ChangeDetectorRef) {
    // 在OutZone中注册监听事件,事件触发时不会引起变更检测
    this.zone.runOutsideAngular(() => {
      // 进一步消除setInterval的变更检测
      this.startTiming();
      this.mouseMoveUnsub =
        this.render2.listen(this.elementRef.nativeElement, 'mousemove', this.mouseLisener.bind(this));
    });
  }

  // 变更检测时会被触发
  ngDoCheck() {
    console.log('rendering...');
  }

  startTiming() {
    console.log('startTiming!');
    this.timer = setInterval(() => {
      // 当检测没到有鼠标事件触发过,则停止检测
      if (!this.isDirty) {
        this.stopTiming();
      }
      this.isDirty = false;
      this.cd.detectChanges();

    }, 5000);
  }

  stopTiming() {
    clearInterval(this.timer);

    // 方法一:通过this.zone.run恢复变更检测
    // this.zone.run(() => {
    //   this.isLogined = false;
    // });

    // 方法二:通过this.zone.run恢复变更检测
    this.isLogined = false;
    this.cd.detectChanges();

    // 解除事件监听
    this.mouseMoveUnsub();
  }

  // 鼠标移动监听
  // @HostListener('mousemove')
  mouseLisener() {
    this.isDirty = true;
    console.log('mousemoved');
  }

  ngOnDestroy(): void {
    this.timer ?? clearInterval(this.timer);
  }
}

总结

本期内容先概念,后示例,通过一个性能优化案例把本期所学的知识实践了一下。从案例中可以看到,当场景适合时,“摆脱”变更检测带来的性能提升是巨大的。同时,你还会发现,我们其实可以不经过zone.js就触发变更检测,而且性能还不错,这是不是说明我们可以抛弃zone.js了呢?

这其实是一个很有意思的话题,尤其在lvy和OnPush策略推出后。在此,我有些个人观点想分享一下。我个人认为虽然我们有很多途径可以摆脱zone.js和变更检测,但是这些“摆脱”都很临时。尤其当我们清楚了zone.js在背后作出的努力后,我们就知道完全让用户自己去控制变更检测是多么恐怖,就好像一夜回到了ajax + jQuery的时代,每一次的UI渲染都需要用户手动执行。所以说,这些“摆脱”方法其实是在前端业务复杂化到一定程度后,同时人们对极致性能的追求到一定程度后所催生的一种产物;是Angular团队为了迎合更广泛的需求上的一种调整。zone.js毕竟给大家带来了太多便利,想要完全放弃会有不少困难。

我之前看到过一个Angular的大佬在讲lvy,视频最后的问答阶段,有位观众问Angular会不会抛弃zone.js?这位大佬大概的回答是:他不认为zone.js会消失。他们只是建议大家在“傻瓜”节点中通过OnPush策略减少对zone.js的使用,但是针对大多数应用,zone.js不会消失。

油管链接奉上,我就不一句一句给大家翻译了。

8_37d6cab0ec647833f38874b78acc67c9_1279x607.png@900-0-90-f.png

9_8514f6faab7ef65ba8a6c073bda77bd8_1277x552.png@900-0-90-f.png

最后的最后,下一期我会详细讲一下InnerZone的执行原理,喜欢的请持续关注~~~

OpenTiny 社区招募贡献者啦

OpenTiny Vue 正在招募社区贡献者,欢迎加入我们🎉

你可以通过以下方式参与贡献:

  • 在 issue 列表中选择自己喜欢的任务
  • 阅读贡献者指南,开始参与贡献

你可以根据自己的喜好认领以下类型的任务:

  • 编写单元测试
  • 修复组件缺陷
  • 为组件添加新特性
  • 完善组件的文档

如何贡献单元测试:

  • packages/vue目录下搜索it.todo关键字,找到待补充的单元测试
  • 按照以上指南编写组件单元测试
  • 执行单个组件的单元测试:pnpm test:unit3 button

如果你是一位经验丰富的开发者,想接受一些有挑战的任务,可以考虑以下任务:

  • ✨ [Feature]: 希望提供 Skeleton 骨架屏组件
  • ✨ [Feature]: 希望提供 Divider 分割线组件
  • ✨ [Feature]: tree树形控件能增加虚拟滚动功能
  • ✨ [Feature]: 增加视频播放组件
  • ✨ [Feature]: 增加思维导图组件
  • ✨ [Feature]: 添加类似飞书的多维表格组件
  • ✨ [Feature]: 添加到 unplugin-vue-components
  • ✨ [Feature]: 兼容formily

参与 OpenTiny 开源社区贡献,你将收获:

直接的价值:

  1. 通过参与一个实际的跨端、跨框架组件库项目,学习最新的Vite+Vue3+TypeScript+Vitest技术
  2. 学习从 0 到 1 搭建一个自己的组件库的整套流程和方法论,包括组件库工程化、组件的设计和开发等
  3. 为自己的简历和职业生涯添彩,参与过优秀的开源项目,这本身就是受面试官青睐的亮点
  4. 结识一群优秀的、热爱学习、热爱开源的小伙伴,大家一起打造一个伟大的产品

长远的价值:

  1. 打造个人品牌,提升个人影响力
  2. 培养良好的编码习惯
  3. 获得华为云 OpenTiny 团队的荣誉和定制小礼物
  4. 受邀参加各类技术大会
  5. 成为 PMC 和 Committer 之后还能参与 OpenTiny 整个开源生态的决策和长远规划,培养自己的管理和规划能力
  6. 未来有更多机会和可能

关于 OpenTiny

OpenTiny 是一套企业级组件库解决方案,适配 PC 端 / 移动端等多端,涵盖 Vue2 / Vue3 / Angular 多技术栈,拥有主题配置系统 / 中后台模板 / CLI 命令行等效率提升工具,可帮助开发者高效开发 Web 应用。

核心亮点:

  1. 跨端跨框架:使用 Renderless 无渲染组件设计架构,实现了一套代码同时支持 Vue2 / Vue3,PC / Mobile 端,并支持函数级别的逻辑定制和全模板替换,灵活性好、二次开发能力强。
  2. 组件丰富:PC 端有100+组件,移动端有30+组件,包含高频组件 Table、Tree、Select 等,内置虚拟滚动,保证大数据场景下的流畅体验,除了业界常见组件之外,我们还提供了一些独有的特色组件,如:Split 面板分割器、IpAddress IP地址输入框、Calendar 日历、Crop 图片裁切等
  3. 配置式组件:组件支持模板式和配置式两种使用方式,适合低代码平台,目前团队已经将 OpenTiny 集成到内部的低代码平台,针对低码平台做了大量优化
  4. 周边生态齐全:提供了基于 Angular + TypeScript 的 TinyNG 组件库,提供包含 10+ 实用功能、20+ 典型页面的 TinyPro 中后台模板,提供覆盖前端开发全流程的 TinyCLI 工程化工具,提供强大的在线主题配置平台 TinyTheme

联系我们:

更多视频内容也可以关注OpenTiny社区,B站/抖音/小红书/视频号。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值