自动跟踪:通过尖端计算机科学提供优雅的开发人员体验

2020-09-24 11:40:37

假定的受众:对一般的反应性模型感兴趣的软件工程师,特别是对web UI和JavaScript感兴趣的软件工程师。

Ember Octane的关键功能之一是自动跟踪,这是一个由兰波特时钟、增量计算和深度优先搜索 - 驱动的轻量级反应性系统,它允许您编写这样的代码,并让它只工作™:

从';@glimmer/component';;import{tracked}from';@glimmer/tracting';;const max_length=10;导出默认类扩展{@tracked name=';';;get(){return this.name.length;}get(){return max_length-this.nameLength;}get(){return this.reisting<;0;}updateName=({target:{value}})=>;this.name=value;}。

关于此代码的反应性方法,有几个有趣的特性需要注意。我们用@Tracked修饰一段状态Name,其余的状态自动更新 - ,包括showError和其他属性,它们甚至不直接引用Name。所有这些都特别轻描淡写:

不需要在getter上标记依赖键(就像在经典的Ember组件中一样),也不需要为派生状态计算散列(就像在Vue 2中一样):这些都是普通的JavaScript getter。

不需要像React的基于类的组件中的setState这样的专用实用程序,也不需要Ember Classic中的set;这段代码只使用标准JavaScript赋值来更新name的值。

这不像以前的Ember那样使用双向绑定,也不像今天的ANGLING或VUE DO 1那样使用双向绑定, - 更新是明确的,但很简短。

当您第一次遇到它时,这可能看起来很神奇, - ,特别是未修饰的getter按需更新的方式。但事实上,它只是javascript™,它构建在标准的javascript模式和计算机科学思想的混合之上,从几十年前久经考验的想法到尖端研究。在这篇文章的其余部分,我们将看看它是如何工作的。

首先,让我们确保对getter在JavaScript中的一般工作方式有一个清晰的了解。一旦您理解了这一点,了解自动跟踪的工作原理就会容易得多。(如果您已经很好地理解了getter与assignment的语义和行为,请随意跳到下一节。)。我们将首先查看与开始时完全相同的类,但是删除了所有的Gimmer和DOM细节,添加了一个构造函数,并继续对updateName:2使用相同的函数样式。

Const max_length=10;导出默认类{name;structor(Name){this.name=name;}get(){return this.name.length;}get(){return max_length-this.nameLength;}get(){return this.reisting<;0;}updateName=(Value)=>;this.name=value;}。

实际上,nameLength属性(从技术上讲是访问器)的执行方式就像它是一个函数一样。在JS拥有原生getter之前,事实上,我们就是这样编写它的,而且实际上我们仍然可以这样编写:

Const MAX_LENGTH=10;导出默认类{name;structor(Name){this.name=name;}nameLength(){return this.name.length;}其余(){return max_length-this.nameLength;}showError(){return this.reisting<;0;}updateName=(Value)=>;this.name=value;}let PersonInfo=new PersonInfo();console.log(PersonInfo.nameLength());

请注意这里的两个不同之处:PersonInfo.nameLength()而不是PersonInfo.nameLength;以及nameLength(){...}而不是get nameLength(){...}。它们实际上是相同的:两者都是计算值的函数。

这里需要注意的另一件事是,方法调用和getter查找都是懒惰的:“它们是按需运行的。在实际调用方法或getter之前,会引用函数作为类的一部分,但不会计算出任何值。这与直接分配属性不同。例如,如果我们在构造函数中分配了nameLength、Leaving和showError的值,它们最初的值将与惰性版本中的值相同,但如果稍后更改name的值,它将立即不同步:

Const MAX_LENGTH=10;导出默认类{name;nameLength;retaining;showError;构造函数(Name){this.name=name;this.nameLength=max_length-this.nameLength;this.showError=this.reisting<;0;}updateName=(Value)=>;this.name=value;}let PersonInfo=new PersonInfo(";Chris";);console.log(PersInfo.nameLength);//5PersonInfo.updateName(";Chris krycho";);console.log(PersonInfo.nameLength);//仍然5😭。

急切地做这件事“意味着我们在为每个派生属性nameLength、Remaining和showError赋值时计算了name、nameLength和reisting的值。我们没有创建引用这些属性的函数,稍后我们可以使用这些属性来计算它们的值。要在构造函数中做到这一点,我们可以将nameLength、reisting和showError定义为箭头函数,利用闭包从其封闭作用域获得对它们使用的值的引用这一事实:34

Const MAX_LENGTH=10;导出默认类{name;nameLength;REVISING;showError;构造函数(名称){this.name=name;this.nameLength=()=>;this.name.length;this.reisting=()=>;max_length-this.nameLength;this.showError=()=>;this.retaining<;0;}updateName=(Value)=>;this.name=value;}let PersonInfo=new PersonInfo(&34;Chris";);console.log(PersInfo.nameLength());//5sonInfo.updateName(";Chris Krycho";);console.log(PersInfo.nameLength());//12。

但是像这样调用PersInfo.nameLength()看起来非常熟悉:它与我们在拥有本机getter之前可能使用的类方法版本相同。换句话说,我们又回到了起点。

函数使用的值仅在调用该函数时求值,无论该函数是独立函数、类方法还是getter。如果我们有一个getter链(或方法或函数),那么直到链的末尾的那个被重新调用,它们中的任何一个都不会被重新调用。在访问使用它们的getter之前,我们不会计算它们引用的任何值。因此,每当我们评估getter时,我们总是会得到所涉及的所有值的最新版本。我们可以向PersonInfo中的getter添加一些日志记录,以查看其行为:

Const max_length=10;导出默认类{name;structor(Name){this.name=name;}get(){console.log(";评估`nameLength`";);return this.name.length;}get(){console.log(";评估`reaining`";);return max_length-this.nameLength;}get(){console.log(";评估`showError`";);返回this.reisting<;0;}updateName=(Value)=>;this.name=value;}。

让PersonInfo=new PersonInfo(";Chris";);console.log(";-1-";);console.log(PersInfo.showError);console.log(";\n-2-";);console.log(PersInfo.nameLength);console.log(";\n--3-";);PersInfo.updateName(";Chris Krycho";);Console.log(Personal Info.reisting);console.log(PersonInfo.showError);

-1-评估`showError`评估`remaining`评估`nameLength`false-2-评估`nameLength`5-3-评估`remaining`评估`nameLength`-2评估`showError`评估`remaining`评估`nameLength`true。

在本例中,我编写的JavaScript在记录这些值时直接求值。当我们在Ember或Glimmer应用程序中使用模板中的值时,模板引擎(Glimmer VM)会计算这些值。VM使用称为自动跟踪的轻量级反应系统来跟踪UI中的哪些项目需要在任何渲染中进行更新。那么,下一步就是了解自动跟踪。

创建单个全局时钟:“单个整数,只会不断增加,6计算系统中任何跟踪的”状态发生了多少次更改。

跟踪“系统中您关心的每一条数据。每当任何跟踪的数据更改时,递增全局时钟(1),并将更新后的全局时钟值与刚更改的数据相关联。

每当您计算模板的值时,7请记下中用于计算的任何跟踪值,并存储它们的全局时钟值。结合(2),这些可以用来知道何时重新计算模板值。

自动跟踪运行时正好实现了这三个想法:(1)连接到跟踪状态(3)的全局时钟(2),以知道何时重新计算模板中的值。全球时钟非常简单:它实际上只是一个整数。更有趣的是其他想法:(2)将跟踪的状态连接到全局时钟,以及(3)使用该连接知道何时重新计算模板中的值。

用@tracked修饰属性会为跟踪的属性设置一个getter和一个setter,并且两者都连接到全局时钟。当你写这个 的时候-。

- 它变成了类似这样的行为,其中markAsUsed表示属性已读取,markAsChanged表示属性已设置:

//这些导入不是从';@glimmer/...';类{//此实现不是真正的导入{markAsUsed(this,';name';);{markAsUsed(this,';name';);return this._name;}set(NewValue){markAsChanged(this,';name';);this._name=newValue;}。

首先,这不是实际的实现 - ,您不能使用装饰器来更改这样的导入! - ,但它是正确的心理模型。8读取跟踪的属性总是调用markAsUsed,设置它总是调用markAsChanged。(这与我们在前面的PersonInfo示例中手动添加的日志没有什么不同!)。

重要的是,如果我们使用引用跟踪属性的getter,情况也完全一样。当我们添加nameLength getter(它通过引用this.name来计算其值)时,使用该getter还会导致markAsUsed运行:

从';@glimmer/Tracking';;class{@tracked name=';';;get(){return this.name.length;}}导入{tracked},让Person=new Person();console.log(Person.nameLength);

首先,@tracked将name转换为一个getter/setter对,就像我们在上面看到的那样。其次,nameLength获取name的值。Name的getter首先运行markAsUsed(this,';name';),然后返回存储在_name中的实际值。无论我们将多少个getter链接在一起,这一点都是正确的:到最后,它们都将最终使用name,这将称为markAsUsed(this,#39;name';name;)。

从';@glimmer/Tracking';;class{@tracked name=';';;get(){return this.name.length;}get(){return Max_length-this.nameLength;}get(){return this.reisting<;0;}updateName=(Value)=>;this.name=value;}let Person=new Person();//Person.showError->;//Person.reisting->;//Person.nameLength->;//Person.name*getter*->;//markAsUsed(this,';name';)//this._name console.log(Person.showError);

类似地,更改name的值将通过@tracked安装的setter调用markAsChanged:

//Person.name*setter*->;//markAsChanged(this,';name';)//this._namePers.name=";Chris";;//Person.updateName->;//Person.name*setter*->;//markAsChanged(this,';name';)//this._namePerson.updateName(";Chris Krycho";);

如果我们从Gimmer组件的模板 - 呈现值或触发更改,则会发生完全相同的事情,如简介中的代码示例所示:

从';@glimmer/component';;import{tracked}from';@glimmer/tracting';;const max_length=10;导出默认类扩展{@tracked name=';';;get(){return this.name.length;}get(){return max_length-this.nameLength;}get(){return this.reisting<;0;}updateName=({target:{value}})=>;this.name=value;}。

在模板中使用this.name直接计算name,这是由@tracked设置的getter,因此调用markAsUsed(this,';name';)。同样,在模板中使用this.showError和this.nameLength将计算这些getter,最终计算name,它再次调用markAsUsed(this,';name';)。调用markAsUsed告诉自动跟踪运行时,this.name用于计算PersonInfo组件模板中的name、nameLength和showError。

通过在输入中键入来触发updateName将调用由@tracked安装的name的setter,并且setter调用markAsChanged(this,';name';)。调用markAsChanged会递增全局时钟值,将更新后的时钟值存储为this.name的新时钟值,并计划重新呈现。

有了这些内容,我们就可以开始了解整个系统是如何工作的。在计算模板中的值时读取@tracked属性会通知Gimmer VM在计算该模板值时使用了该属性。更改@tracked属性会增加全局时钟和属性时钟值,并安排新的渲染。这就引出了想法(3):使用全局时钟值来知道何时重新计算模板中的值。

当呈现模板时,9运行时在UImarkAs值、组件、帮助器、修饰符等中为每个新计算设置所谓的跟踪帧。跟踪帧基本上只是在计算模板中的任何特定值时调用 - 所使用的所有跟踪属性的列表。由于每个跟踪帧对应于UI的动态元素,因此在第一次呈现整个UI时对其进行评估会产生与UI组件树完全对应的跟踪帧树。不过,重要的是,跟踪帧不存储在其计算过程中引用的跟踪属性的值。相反,该帧仅存储对每个属性的引用以及该属性的当前和以前的全局时钟值。

在正常的JavaScript调用中,没有活动的跟踪框架,因此调用markAsUsed是不可行的。渲染时,跟踪帧确实存在,并且最终使用计算该值时使用的所有跟踪属性的时钟值填充该帧。当给定的跟踪帧关闭时“,如在组件调用结束时,它计算它自己的时钟值。跟踪帧的时钟值是标记为在该帧中使用的任何属性的最大时钟值。因为时钟值是整数,所以这个最大时钟值可以非常简单地计算出来:使用Math.max。10个。

正如我们在上面看到的,更改通过设置跟踪属性进入系统。回想一下,调用markAsChanged会同时影响该属性的全局时钟值和时钟值,并安排新的呈现。11当微光VM重新渲染时,它可以深度优先搜索遍历树,比较每个帧的当前时钟值和缓存的时钟值。如果给定帧的时钟值没有改变,那么UI树中它下面的任何东西都没有改变,或者是 - ,这样我们就知道不需要重新呈现它了。检查该时钟值是否已更改实际上只是整数相等检查。在已更改的节点上,VM计算新值并使用结果更新DOM。

重新渲染几乎是尽可能便宜:所有的状态计算都是简单的整数运算。

当它所依赖的状态改变 - 但使用正常的JavaScript语义时,会按需计算“中间派生”状态,而不会给开发人员带来额外的样板或最终用户对性能的影响。

如果您需要的话,在这些语义之上分层您自己的缓存或记忆是微不足道的,但是您只需为您需要的东西买单。

所有的智能“都生活在系统的最边缘,处于根状态,用@Traced标记,叶值在被动上下文(如模板)中计算。

希望这能让您很好地了解自动跟踪一般是如何工作的,特别是它是如何同时使我们的大部分代码只是JavaScript的“,并为我们提供了非常低成本的反应性。

如果您想了解这些组件是如何实现的,请查看我与Ember核心团队成员兼Gimmer VM贡献者Chris Garrett(@pzuraq)的对话视频。克里斯还在EmberConf 2020大会上发表了一场关于自动跟踪的精彩演讲,并写了一系列关于这个主题的博客文章:

对自动跟踪的基础感兴趣的读者可能想看看Adapton,这是增量计算的特定理论的原始研究实现“支撑自动跟踪”。有关相同思想的另一个“现实世界”实现,请查看salsa:支持Ruust分析器语言服务器的增量计算的Rust实现。

我们可以在这里切换到类方法,但稍后当我们再次回到组件代码时,我们只需切换回来。对于正在阅读这篇文章的Ember用户:是的,您可以使用这种方法,尽管使用@action目前是惯用的。↩︎。

这实际上是Reaction Hooks如何在幕后工作的关键部分。↩︎。

同样值得一看的是,闭包是类的双重属性。对于最终用户而言,这两者具有相同的语义:

类{_age;_name;构造函数(name,age){this._age=age;this._name=name;}get(){return`${this._name}是${this._age}岁!`;}haveABirthday(){this._age+=1;}changeNameTo(Newname){this._name=newname;}}function(name,age){let_name=name;let_age=age;Return{get(){return`${_name}is${_age}old!`;},haveABirthday(){_age+=1;},changeNameTo(Newname){_name=newname;},}

↩︎。

今天用于Ember模板层的这些相同的想法 - , - 也可以用来在完全不同的反应性模型中实现即付即用反应性。例如,您可以使用它重新实现MobX或Redux。↩︎。

今天,Ember唯一的反应性上下文是其模板层,在模板层中呈现的值或作为参数传递给组件、修改器或帮助器的值都是反应性的。不过,我们很快就会在javascript上下文中提供反应性函数,这将使反应性系统变得完全通用!↩︎。

在实际实现中,@tracked实际上是使用另一个模块中的闭包实现的,该模块使用名为consumeTag和dirtyTagFor的函数。函数名称中引用的标记是存储给定跟踪数据的全局时钟值的轻量级对象。有关实现的演练,请参阅我和Chris Garrett在帮助我填补对这一切理解上的一些空白时录制的Gimmer VM视频中的跟踪。↩︎。

这里有一些关于它如何检查树并确保它正确管理其内部状态的详细信息,但它实际上使用的是Math.max。↩︎。

VM将这些凹凸合并在一起,因此如果您设置一系列值来响应用户操作或API响应或其他输入,它只会触发一次重新呈现,而不是多次。↩︎