关于javascript:zonejs由入门到放弃之一通过一场游戏认识zonejs

101次阅读

共计 12878 个字符,预计需要花费 33 分钟才能阅读完成。

之前有写过一些介绍 Angular 中一些理念的文章,接下来咱们来聊聊 Angular 中的一些依赖,比方 zone.js。它是一个跨多个异步工作的执行上下文,在拦挡或追踪异步工作方面有着特地弱小的能力。来跟着啸达同学的文章,一起理解一下吧~

前言

最近一段时间因为工作上的安顿,须要钻研 Angular 中的一些外部机制和模块。Angular 作为一个专门为大型前端我的项目而设计的优良框架,实际上有很多值得大家学习和借鉴的长处的。之前理解到 Angular 的变更检测跟 Vue 和 React 有实质的区别,而 Angular 的检测体系是离不开 zone.js 的,所以本系列就针对 zone.js 进行一些分享,也心愿可能随着集体对 zone.js 逐渐学习制作一个由浅入深地学习领导,欢送大家踊跃上车,一起学习、探讨。


为什么要学习 zone.js

这个系列的文章会蕴含大量的猜测、验证、demo 和源码剖析。在我本人学习的过程中,也呈现过屡次想要放弃,或者感觉差不多就行了的想法。所以如果想要保持一件事,须要有一些明确的动机,毕竟动机不纯,想装纯也难。那么就我集体而言,除了工作上的须要之外,我感觉有以下两点驱动力:

动机一

17 年的时候,有幸加入了一次大厂的面试,技面的时候我被问到 Angular 是如何解决变更检测的。我对这块的常识非常含糊,所以乱答了一气。我把脑子里跟变更检测相干的词都掏了进去,后果越描越黑,最初不能自圆其说。面试官跟我说,心愿我 当前 把这块内容理理顺。我许可了他,但没想到竟是 6 年当前。

动机二

置信每个 Angular 的开发者都会见过相似上面这样的报错信息,甚至有些初学 Angular 的共事也因而感觉 Angular 的学习曲线比拟平缓、错误信息极不敌对。其实,大家认为这些不敌对的错误信息正是 zone.js 弱小的中央。在不理解 zone.js 的前提下,这的确有点反人类。所以心愿在学完这个系列后,不仅晓得这样的谬误意味着什么,还能分明这样的问题是怎么产生的。

at HTMLButtonElement.throwError (https://zonejs-basic.stackblitz.io/~/index.js:19:11)
at _ZoneDelegate.invokeTask (https://unpkg.com/zone.js:446:35)
at Zone.runTask (https://unpkg.com/zone.js:214:51)
at ZoneTask.invokeTask [as invoke] (https://unpkg.com/zone.js:528:38)
at invokeTask (https://unpkg.com/zone.js:1730:22)
at globalCallback (https://unpkg.com/zone.js:1761:31)
at HTMLButtonElement.globalZoneAwareCallback (https://unpkg.com/zone.js:1797:20)
at ____________________Elapsed_496_ms__At__Fri_Jan_20_2023_16_20_20_GMT_0800_________ (http://localhost)
at Object.onScheduleTask (https://unpkg.com/zone.js@0.8.20/dist/long-stack-trace-zone.js:108:22)
at _ZoneDelegate.scheduleTask (https://unpkg.com/zone.js:426:55)
at Zone.scheduleTask (https://unpkg.com/zone.js:257:47)
at Zone.scheduleEventTask (https://unpkg.com/zone.js:283:29)
at HTMLButtonElement.addEventListener (https://unpkg.com/zone.js:2038:37)
at HTMLButtonElement.bindSecondButton (https://zonejs-basic.stackblitz.io/~/index.js:16:8)
at _ZoneDelegate.invokeTask (https://unpkg.com/zone.js:446:35)
at Zone.runTask (https://unpkg.com/zone.js:214:51)
at ____________________Elapsed_1801_ms__At__Fri_Jan_20_2023_16_20_18_GMT_0800_________ (http://localhost)
at Object.onScheduleTask (https://unpkg.com/zone.js@0.8.20/dist/long-stack-trace-zone.js:108:22)
at _ZoneDelegate.scheduleTask (https://unpkg.com/zone.js:426:55)
at Zone.scheduleTask (https://unpkg.com/zone.js:257:47)
at Zone.scheduleEventTask (https://unpkg.com/zone.js:283:29)
at HTMLButtonElement.addEventListener (https://unpkg.com/zone.js:2038:37)
at main (https://zonejs-basic.stackblitz.io/~/index.js:5:8)
at _ZoneDelegate.invoke (https://unpkg.com/zone.js:412:30)
at Zone.run (https://unpkg.com/zone.js:169:47)

简略认识一下

我不太会形象概括,幸好 Angular 团队对 zone.js 的定义只有一句,然而它简略形象到让你看了和没看都没什么区别:

A Zone is an execution context that persists across async tasks. You can think of it as thread-local storage for JavaScript VMs.

到目前为止,我感觉大家也不用太在意这里形容了个啥,前面我会用其它的形式让你慢慢理解它。这里权且对几个词有点印象即可:

  • 执行上下文:execute context
  • 放弃:persist
  • 异步工作:async tasks
  • 有 Java 背景的能够回顾一下 ThreadLocal 的作用和用法;用过 JS 沙箱的也能够类比一下。都没用过的也不碍事,不影响前面的浏览。

PS:Angular 团队对 zone.js 还有个视频介绍,倡议大家能够等看完这篇文章后去理解一下。

ngZone 和 zone.js,傻傻分不清

最初在真正开始之前我要再补充一个知识点,有些人在学习 zone.js 的还见过 ngZone 这个货色,就认为这两个是一个货色。这里做个简略的申明,Angular 团队基于 zone.js 构建了 ngZone 服务。NgZone 定义 Angular 的执行上下文,能够先简略了解为是一个专门给 Angular 应用的定制化当前的 zone.js。那么对于 ngZone 的常识,能够关注一下系列四和西系列五(如果有的话),在那里会有对 ngZone 和 Angular 具体的变更检测办法做具体介绍。

所以一句话概括两者的关系,ngZone 生于 zone.js;长于 Angular(生于斯,长于斯)。

从一个游戏开始理解 zone.js

本文由实在事迹改编,如有雷同,绝非偶尔

2022 年底,自己工作的部门组织了一场 switch 对决赛。游戏有 A、B 两支参赛队伍,每支队伍 15 人。要求每天两队之间进行 3 场对决,较量一共会进行 5 一天,获得积分劣势的团队获胜(输的那队要请赢的队伍吃饭)。主办方提供了 3 款游戏:第一场盘旋镖;第二场马 8;第三场明星大乱斗。

如果只是关怀哪里有这么好的部门的话,能够间接留言

既然游戏的品种和较量程序是固定的,那么每天各队派出的 3 位参赛选手的程序就很重要:比方能够让相熟某款游戏的选手去比对应的游戏;或者通过田忌赛马的形式消耗对方的后劲。那么,咱们明天的示例就从队长选人开始:

第一版:无脑排兵被偷窥

这里咱们有两支参赛队伍 teamA、teamB;这里假如 teamA 只能程序排人,teamB 只能倒序排人。同时还有一个裁判,负责收集 teamA、teamB 两支队每天的排序状况。下图中代码大略意思就是,随着裁判一声令下,teamA、teamB(代码 AB 是程序执行的,然而读者在这里先别纠结)别离开始排名布阵,排好之后,裁判函数打印两队排序:

// demo0/demo0.js

const teamA = {
  name: 'teamA',
  team: [],
  sort: function() {this.team.push(1);
    this.team.push(2);
    this.team.push(3);
  }
};

const teamB = {
  name: 'teamB',
  team: [],
  sort: function() {// console.log(`${this.name}偷看 ${teamA.name}排名布阵, ${teamA.name}以后阵容是: `, teamA.team);
    this.team.push(3);
    this.team.push(2);
    this.team.push(1);
  }
};

function judgement() {teamA.sort();
  teamB.sort();

  console.log('teamA:', teamA.team);
  console.log('teamB:', teamB.team);
}

judgement();

// teamB 偷看 teamA 排名布阵, teamA 以后阵容是:  [1, 2, 3]
// [console.log] teamA:  [1, 2, 3]
// [console.log] teamB:  [3, 2, 1]

然而偏偏有年轻人不讲武德,耗子尾汁,在两队排兵期间偷窥对方阵容:如上文中正文代码所示,teamB 的队长在 teamA 排阵过程中轻轻打印出 teamA 的阵型,导致 teamB 队长能够针对性地进行兵力调整,已达到最好的成果。

这里真的想点名批评一下伍队长,不讲武德的人就是你

那么造成上述问题的起因次要是因为 teamA 和 teamB 对彼此都是可见的,即两队在排兵布阵的过程对对方是齐全袒露的,导致让对手有了可乘之机,所以这也是接下来要调整的重点。

第二版:小黑屋中探讨军机

为了不让两队在排兵中通晓对方的阵容,须要将两队进行隔离。JS 中进行数据隔离有很多方法,从晚期的闭包、后续有了通过模块 (文件) 进行隔离,到当初在 JS 中也能够应用面向对象的编程思维。这里,咱们就先通过模块将两队隔离起来,文件构造如下:

    ├─demo1
    │  ├─teamA.js
    │  └─teamB.js
    │  └─judgement.js

teamA 与 tramB 中代码相似:

// demo1/teamA.js

const teamA = {
  name: 'teamA',
  team: [],
  sort: function() {this.team.push(1);
    this.team.push(2);
    this.team.push(3);
  }
};

module.exports = teamA
// demo1/teamB.js

const teamB = {
  name: 'teamB',
  team: [],
  sort: function() {this.team.push(3);
    this.team.push(2);
    this.team.push(1);
  }
};

module.exports = teamB
// demo1/judgement.js

const teamA = require('./teamA');
const teamB = require('./teamB');

function judgement() {teamB.sort();
  teamA.sort();

  console.log('teamA:', teamA.team);
  console.log('teamB:', teamB.team);
}

judgement();

// teamA:  [1, 2, 3]
// teamB:  [3, 2, 1]

这一次,通过裁判程序将 teamA 和 teamB 导入,各队排序过程绝对独立不受烦扰。

第三版:容我想想

隔离的问题尽管解决了,这时 teamB 的队长感觉每次排序都太仓促了,须要把人员排序的工作领回去跟团队协商一下才行。这里咱们应用异步工作来模仿各位队长将工作带回去排序的成果。

文件构造如下:

    ├─demo2
    │  ├─teamA.js
    │  └─teamB.js
    │  └─judgement.js
    │  └─thinking.js

这一次,咱们新增一个冥想程序:thinking.js,这里提供一个函数,通过异步的 setTimeout 随机期待 0 3 秒。两位队长针对每次较量的出场人员程序进行认真地思考,这里应用延时 0 3 秒的 thinking.js 模块模仿队长做出决定的过程。

// demo2/thinking.js

// 获取 0~3 随机数
function getRandomSec() {return Math.random() * 3;
}

module.exports = function(cb) {const random = getRandomSec() * 1000;
  setTimeout(cb, random);
}

队长代码示例:

// demo2/teamA.js

const thinking = require('./thinking');

const teamA = {
  name: 'teamA',
  team: [],
  sort: function() {
    // 此处容我想想
    thinking(() => {this.team.push(this.team.length + 1);
    });
    thinking(() => {this.team.push(this.team.length + 1);
    });
    thinking(() => {this.team.push(this.team.length + 1);
    });
  },
};

module.exports = team
// demo2/teamB.js

const thinking = require('./thinking');

const teamB = {
  name: 'teamB',
  team: [],
  sort: function() {thinking(() => {this.team.unshift(this.team.length + 1);
    });
    thinking(() => {this.team.unshift(this.team.length + 1);
    });
    thinking(() => {this.team.unshift(this.team.length + 1);
    });
  },
};

module.exports = team

到这里,两个队长把各组的工作领回去了,可对战室里还有个裁判呢。因为之前 AB 两组霎时就把阵容排好了,裁判马上就能晓得各队的排序后果。当初大家都回去各排各的了,把裁判一个人晾这了。而且,这个裁判不晓得要等多久两位队长能力把人排完(每个队长都须要 0~3 秒),所以裁判只能无奈地依照最长工夫进行期待,即裁判要期待 3 秒再回来收集大家的后果:

// demo2/judgement.js

const teamA = require('./teamA');
const teamB = require('./teamB');

function judgement() {teamB.sort();
  teamA.sort();

  setTimeout(() => {console.log('teamA:', teamA.team);
    console.log('teamB:', teamB.team);
  }, 3000);
}

judgement();

// 苦等 3 秒出后果
// teamA:  [1, 2, 3]
// teamB:  [3, 2, 1]

第四版:zone.js 版本

当你感觉所有人应该都满足的时候,裁判站进去了说他不称心。裁判不违心始终在那里傻等后果,心愿大家能在排序结束后第一工夫告诉他,避免浪费工夫。接下来就来看一下 zone.js 是如何解决这些问题的。

PS:这里大家先不要纠结 API 的用法和一些具体概念,前面的文章会一点一点给大家扫盲,先通过示例感受一下 zone.js 的性能。

示范前还是再廓清一下几个比拟重要的需要:

  • 两队的排序数据须要隔离
  • 两队排序时须要有思考工夫(0~3s)
  • 裁判要第一工夫晓得两队排序已完结,并颁布后果

前文介绍中说过,zone.js 的一个要害概念是执行上下文,过后咱们说能够把这个异步上下文类比成 Java 的 LocalThread,即能够在单个线程内共享数据。那么在 JS 中,这个执行上下文也是有类比的,能够把它设想成一个沙箱——一个 JS 的 VM。在这个沙箱中,你能够把你的 JS 代码放在沙箱中运行,同时沙箱也有一个上下文的概念,这是一段共享的内存空间,能够供运行在沙箱中的代码所应用;同时沙箱和沙箱之间互相隔离、无奈互相烦扰。

Mark1:创立一个 zone,zone.js 通过 fork 办法能够创立一个 zone,咱们能够先了解它就是一个沙箱。
Mark2:zone.js 中有一个静态方法,能够获取到 zone,Zone.current

有了这两个办法,就能够别离为 teamA 和 teamB 创立两个 zone。通过上面示例可见,代码中别离创立两个 zone,他们别离持有 teamA 和 teamB 的对象,而 teamA、B 对象保留在 zone 的 properties 中。

// demo3/judgement.js

require('zone.js');
const thinking = require('./thinking');

// 创立 zone
// Zone.current 获取以后 zone;以后 zone 为 rootZone
// Zone.current.fork  创立一个基于以后 zone 的子 zone
const zoneA = Zone.current.fork({
  // zone 的名字
  name: 'teamA',

  // zone 中能够通过 properties 设置一段共享内存
  properties: {
    // teamA 对象
    team: {
      name: 'teamA',
      team: [],
      sort: function() {thinking(() => {this.team.push(this.team.length + 1);
        });
        thinking(() => {this.team.push(this.team.length + 1);
        });
        thinking(() => {this.team.push(this.team.length + 1);
        });
      },
    },
  },
});

const zoneB = Zone.current.fork({
  name: 'teamB',
  properties: {
    team: {
      name: 'teamB',
      team: [],
      sort: function() {thinking(() => {this.team.unshift(this.team.length + 1);
        });
        thinking(() => {this.team.unshift(this.team.length + 1);
        });
        thinking(() => {this.team.unshift(this.team.length + 1);
        });
      },
    },
  },
});

下面代码中,teamA 和 teamB 不再被别离定义到两个文件中了,为了验证两个队伍的数据是否可能被不同的 zone 隔离开,咱们别离在 zoneA 和 zoneB 中执行雷同的代码(打印 properties 中的组名)。为此,咱们还须要理解一下 zone.js 提供的另外两个 API。

Mark3:zone.js 提供一个 run 办法,能够在 zone 中执行一段代码
Mark4:zone.js 提供一个 get 办法,能够获取以后 zone 的 properties 属性

// 在 zoneA 的上下文中执行函数
zoneA.run(() => {
  // 获取以后 zone
  const currentZone = Zone.current;
  // 从 properties 中获取 team 属性
  const team = currentZone.get('team');
  console.log(team.name); // tramA
});

zoneB.run(() => {
  const currentZone = Zone.current;
  const team = currentZone.get('team');
  console.log(team.name); // tramB
});

能够看到,两个 zone 中数据互相隔离,在 run 的作用域中,只能获取到本人 zone 中的数据。

第一步革新

这里,咱们首先做到了让 teamA 和 teamB 的数据隔离。两位队长把每队的人员信息都保留在各自的 zone 中,并在各自 zone 的上文中执行排序工作。整个工作期间,两个 zone 互不干涉。

function judgement() {

  // teamA 领工作回去
  zoneA.run(() => {
    const currentZone = Zone.current;
    const team = currentZone.get('team');
    team.sort();});

  // teamB 领工作回去
  zoneB.run(() => {
    const currentZone = Zone.current;
    const team = currentZone.get('team');
    team.sort();});

  // 裁判 3s 后收集后果
  setTimeout(() => {
    // 打印 teamA 的后果
    zoneA.run(() => {
      const currentZone = Zone.current;
      const team = currentZone.get('team');
      console.log('teamA:', team.team);
    });
    // 打印 teamB 的后果
    zoneB.run(() => {
      const currentZone = Zone.current;
      const team = currentZone.get('team');
      console.log('teamB:', team.team);
    });
  }, 3000);
}

judgement();

然而上述代码还有俩个问题:

  • 裁判还是要期待 3s 后能力晓得两位队长的人员排序
  • 因为数据隔离,裁判也不晓得两位队长的人员排序后果。裁判只能委托两个队长本人打印排序后果

有没有方法让裁判本人能感知到两位队长何时排序完结,而后在两位队长排序完第一工夫发表排序后果呢?

其实如果大家认真看下 zone.js 对 fork 办法的定义后就能晓得,fork 实际上只是创立出一个 child zone。zone.js 在初始化的时候回创立一个根 zone,而后所有的通过 fork 后会在根 zone 下创立一个子 zone。也就是说,zone 是具备继承关系的,官网习惯把这种关系叫做 zone 的可组合性。而每个子 zone 都保留了其父 zone 的对象;每个父 zone 也能监听到子 zone 的事件。

Mark5:可组合性:每个子 zone 都保留了其父 zone 的援用;每个父 zone 也能监听到子 zone 的事件。

每个子 zone 都保留了其父 zone 的援用这个好了解,那么每个父 zone 也能监听到子 zone 的事件怎么了解?其实这个就是 zone.js 最神奇的中央,zone.js 在初始化的时候对很多 API 都做了“手脚”——Monkey Patch,将这些异步办法封装成了 zone.js 中的异步工作。同时,因为在这些工作中定义很多勾子函数,导致 zone.js 能够齐全监控这些异步工作的整个生命周期。

Mark6:追踪异步工作

正是因为 zone 的这种个性,使得 zone 被常常地用于异步工作的跟踪和调试中。比方上文在动机 2 中展现的那个难以了解的谬误堆栈,就是 zone 跟踪异步异样的后果。

终极革新

最初这一版,咱们给裁判也 fork 出一个 zone,而 teamA 和 tramB 的 zone 都是 fork 自裁判 zone 的。这么解决后,裁判 zone 中就能够检测到 teamA 和 tramB 中异步工作执行的全副生命周期。其中,示例中只是用了 zone.js 泛滥勾子中的一个——onHasTask。这个函数会在执行队列中退出函数或没有函数时被调用。

本例中,teamA 执行结束后会把执行后果更新到裁判 zone 中;teamB 也做同样的事。当两队都完结排序后,裁判 zone 通过配置的回调函数 第一工夫 打印两位队长的排序后果。至此,该示例满足了咱们上述的所有需要。

源码奉上:
require('zone.js');
const thinking = require('./thinking');

// 创立一个裁判 zone,当做 teamA 和 teamB 的父 zone
const zoneJudgement = Zone.current.fork({
  name: 'judgement',
  properties: {
    // 寄存 teamA、teamB 的排序后果
    result: [],},

  // 异步工作状态扭转时的回调
  onHasTask: function (parentZoneDelegate, currentZone, targetZone, hasTaskState) {
    
    // setTimeout 属于宏工作,!hasTaskState.macroTask 标识有宏工作执行结束
    if (!hasTaskState.macroTask) {
      // 裁判工作执行完结
      switch (targetZone.name) {
        case 'judgement':
          console.log(currentZone.get('result'));
          break;
        // A 组排序工作执行完结
        case 'teamA':
          currentZone.get('result').push({teamA: targetZone.get('team').team,
          });
          break;
        // B 组排序工作执行完结
        case 'teamB':
          currentZone.get('result').push({teamB: targetZone.get('team').team,
          });
          break;
        default:
          break;
      }
    }
    // 事件上抛
    parentZoneDelegate.onHasTask(parentZoneDelegate, currentZone, targetZone, hasTaskState);
  }
});

const zoneA = zoneJudgement.fork({
  name: 'teamA',
  properties: {
    team: {
      name: 'teamA',
      team: [],
      sort: function() {thinking(() => {this.team.push(this.team.length + 1);
        });
        thinking(() => {this.team.push(this.team.length + 1);
        });
        thinking(() => {this.team.push(this.team.length + 1);
        });
      },
    },
  },
});
const zoneB = zoneJudgement.fork({
  name: 'teamB',
  properties: {
    team: {
      name: 'teamB',
      team: [],
      sort: function() {thinking(() => {this.team.unshift(this.team.length + 1);
        });
        thinking(() => {this.team.unshift(this.team.length + 1);
        });
        thinking(() => {this.team.unshift(this.team.length + 1);
        });
      },
    },
  },
});

function judgement() {zoneA.run(() => {
    const currentZone = Zone.current;
    const team = currentZone.get('team');
    team.sort();});

  zoneB.run(() => {
    const currentZone = Zone.current;
    const team = currentZone.get('team');
    team.sort();});
}

zoneJudgement.run(judgement); // [{ teamA: [ 1, 2, 3] }, {teamB: [ 3, 2, 1] } ]

总结

自此,通过一个小例子展现了一下 zone.js 的性能,同时依据例子浅述了一下 zone.js 的几个特点。前文为了不便了解,始终把 zone.js 类比成 LocalThread 或者沙箱。其实,zone.js 的能力远不止这些类比的对象,它还被大量用在解决异步工作和异步谬误的跟踪中。至此,大家别忘了看下 Angular 团队给出的 zone.js 的视频介绍,能够更好地加深一下对本文的印象。

接下来,对于 zone 这个名字,个人感觉起的很到位(老外起名字总是很讲究的)。zone 被翻译成地区、区域。就拿咱们国家的区域划分来说,国家、省、市、区、街道 … 每个同级的地区划分都是互相隔离的,一级一级区域划分又是能够嵌套的。不得不说,这种嵌套又隔离的特点在下面示例中展现的酣畅淋漓。

这是本系列的第一篇文章,只是浅浅地入门了一下 zone.js,后续会针对 zone.js 的 API、源码、以及如何跟 Angular 配合做进一步剖析阐明,感兴趣的能够蹲个续~

对于 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
  • OpenTiny 官网:https://opentiny.design/
  • OpenTiny 代码仓库:https://github.com/opentiny/
  • Vue 组件库:https://github.com/opentiny/tiny-vue(欢送 Star)
  • Angluar 组件库:https://github.com/opentiny/ng(欢送 Star)
  • CLI 工具:https://github.com/opentiny/tiny-cli(欢送 Star)

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

正文完
 0