写点什么

zone.js 由入门到放弃之一——通过一场游戏认识 zone.js

作者:OpenTiny社区
  • 2023-08-24
    中国香港
  • 本文字数:9752 字

    阅读完需:约 32 分钟

zone.js由入门到放弃之一——通过一场游戏认识zone.js

之前有写过一些介绍 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 随机等待 03 秒。两位队长针对每次比赛的出场人员顺序进行认真地思考,这里使用延时 03 秒的 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的子zoneconst 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的父zoneconst 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 社区,B 站/抖音/小红书/视频号。

用户头像

还未添加个人签名 2023-06-06 加入

我们是华为云的 OpenTiny 开源社区,会定期为大家分享一些团队内部成员的技术文章或华为云社区优质博文,涉及领域主要涵盖了前端、后台的技术等。

评论

发布
暂无评论
zone.js由入门到放弃之一——通过一场游戏认识zone.js_前端_OpenTiny社区_InfoQ写作社区