逐步改进DOM

2020-08-04 22:58:07

上一次,我试图说服您,您可能不需要虚拟DOM,并且许多常见的UI模式可以用完全静态的页面重现,更改只发生在树属性和文本节点的叶子上。对于一些更复杂的UI模式,我重新添加了有限形式的动态行为,允许元素具有动态的子列表。

这可能并不令人惊讶,因为在Reaction普及虚拟DOM(使用Mustache模板之类的东西)之前,这毕竟是我们过去所做的事情。

动态数组针对数组末尾的修改进行了优化。阵列中间的修改可能会触发对阵列末尾节点的级联更新。在实践中,这不是一个大问题,但对于大型阵列,它可能会成为性能问题。此问题的一种解决方案是为循环创建另一种结构,其中内部模板不能访问其索引,或者索引与父数组中元素的位置不对应。

为了触发UI更改,无论更改多么微小,我们都需要为整个静态DOM组件构造一个新模型。同样,在实践中,这不是一个大问题,但它确实增加了做某些事情的难度。例如,如果我们想要将模型更改发送到服务器进行评估,我们将会遇到困难。

静态DOM中的每个节点都可能观察到每个更改。我们可以使用一些技巧,比如从事件流中过滤出重复的事件,但这会占用不必要的时间和CPU周期。回想一下,静态DOM的动机是我们直观地知道哪些元素应该接收小的模型更改的事件,比如更改单个文本节点。挑战是要让机器相信,子模型和元素之间的这种联系是显而易见的!

在这篇文章中,我想建议一种不同的方法,它既解决了这些问题,又保留了静态DOM方法的优点。

蔡、Giarrusso、Rendel和Ostermann的论文“高阶语言的变化理论”在摘要中阐述了以下内容:

如果昂贵的计算结果因对输入的微小更改而无效,则应该增量更新旧结果,而不是重新执行整个计算。

这听起来很像适用于我们的问题!一旦我们计算了DOM的初始状态,对模型的微小更改应该会导致对DOM的微小更改。

事实上,正如我们将看到的,增量lambda演算将为上面列出的所有三个问题提供解决方案。

变化理论...通过在新的上下文中解释Lambda演算的类型和术语来进行,在新的上下文中,每种类型都增加了变化的结构。

对于我们的目的,改变结构等价于作用于类型值的么半群。我使用以下类型类实现更改结构:

Class Monoid m<;=Patch a m|a->;m,其中patch::a->;m->;a。

该声明声明在载体类型A和必须是么半群的改变结构M之间存在函数关系。我使用函数依赖将更改结构表示为载体类型的函数。在实践中,这意味着在相当多的地方使用新类型,但使类型推断更令人愉快。

例如,最后一个么半群通过新型原子a作用于类型a的值:

导入数据。可能.Lastnewtype原子a=原子实例补丁原子::补丁(原子a)(Last A)WHERE补丁(原子a)(最后m)=案例m of Nothing_->;原子a仅b->;原子b

Mempty不执行任何操作,保持当前值,当合成多个Last a值时,最后一个值获胜。原子a是具有微小改变结构的类型a的值,其中该值或者根本不改变,或者完全改变。

本文还定义了元组(其中两个组件可以独立更改)、函数和其他结构(如包(允许具有重复元素的集合))的更改结构。

通过对每一种类型和术语的解释,本文能够将简单类型的λ演算中的任何一个术语解释为增量函数。增量函数是指既可以正常计算,也可以在输入发生变化的情况下对输出产生变化的函数。

在我的purescript增量函数库中,我使用了一种不同的方法,保留了更改结构的概念,但使用嵌入式DSL实现增量函数。具体地说,我使用了一种基于高阶抽象语法的方法,其中增量函数使用常规的PureScript函数表示。

从高阶抽象语法的角度给出增量λ演算的嵌入是可能的(如果你已经读过我的另一篇博客文章),这或许并不令人惊讶,但我在这里使用的嵌入实际上并不是我在那篇博客文章中描述的嵌入-它要简单得多。

Jet是类型a的值,与类型为change a的更改成对,其中change a是作用于a的更改结构。我说更改结构是唯一的,因为对Patch的函数依赖使其唯一。

更改是使用类似关联类型的东西定义的。在PureScript中,与在GHC Haskell中不同,我们没有关联的类型,但我们可以通过将函数下的(唯一)类型打包为抽象数据类型并使用unsafeCoells构造值(安全!)来进行粗略的近似:

数据更改来自Change::for a da。修补a da=>;将a->;dafrom a->;daFromChange=unsafeCoercto Change::for all a da。修补程序a da=>;da->;change atoChange=unsafe强制

我们应该认为值Jet{position:x,ocity:dx}当前位于x,并且即将以dx的量移动。这可能会让人联想到自动微分中的双重数字,即我们将一个数字与其变化率配对。

给定Jet的定义,增量函数的编码很简单:从a到b的增量函数(及其相关的更改结构)由从Jet a到Jet b的函数表示。

下面是一个简单的示例-从类型a的原子值到类型b的原子值的增量函数,由从a到b的正则函数构造:

贴图原子::forall a b.(a-gt;b)->;Jet(原子a)->;Jet(原子b)贴图原子f{位置,速度}={位置:原子(f(非原子位置)),速度:更改(贴图f(来自更改速度))}。

这是一个简单的示例,但是我们可以创建许多标准函数的增量版本:地图、折叠、过滤、压缩等等。Purescript增量函数定义了一个小型的增量数据结构标准库,比如数组、映射和记录,以及类似这样的增量函数。

为了说明重要的一点,这里有另一个示例-用于增量映射数据结构的API和在其上映射函数的函数:

Data IMAP k ADATA MapChange a da=Insert a|Remove|Update数据类型MapChanges k a da=Map k(MapChange A Da)--^每个键映射的潜在更改::forall k a da b db。Ord k=>;Patch a da=>;Patch b db=>;(Jet a->;Jet b)->;Jet(IMAP K A)->;Jet(IMAP K B)。

请注意,这里使用JET函数来构造高阶增量函数,因为要映射的(增量)函数是作为参数传入的。

因为JET函数只是常规函数,所以我们可以像函数一样组合它们,使用lambda抽象来形成新函数,等等。我们通过将更改从一个函数传递到另一个函数来传播更改。例如:

地图原子(_+1)::Jet Int->;Jet Intmap(地图原子(_+1))::Jet(IMAP K Int)->;Jet(IMAP K Int)\f->;map(Map F)::(Jet a->;Jet b)->;Jet(IMAP K1(IMAP K2))->;Jet(IMAP K1(IMAP K2。

如果我们斜视到足以看透Jet类型的构造函数,那么这个DSL非常接近普通的旧函数,但是我们的数据结构已经换成了增量等价物。当然,我们仅限于那些我们可以递增编写为JET函数的基本函数。

每个增量函数都可以表示为JET函数,但并不是每个JET函数都是有效的增量函数。我们要求JET函数f::JET a->;Jet b满足以下条件:

下部::(JET a->;Jet b)->;a->;鼓风机f a=(f{位置:A,速度:记忆}).位置。

也就是说,如果我们按规则(不变)值将函数f降低到某个函数,并将其应用于修补后的值,则应该会得到与应用降低后的函数并使用JET函数生成的修补程序修补结果相同的结果。

然而,请注意,以下条件可能在直觉上看起来很明显,但通常并不成立:

也就是说,JET函数不需要将恒定的JET转换为恒定的JET。原因是JET函数可能会关闭其环境中一些已经在更改的值,在这种情况下,结果中的更改将已经包含在该JET函数中。

变更结构告诉我们,变更如何作为纯函数作用于值,但我们可以使用这些纯函数对真实世界的不纯变更进行建模。

例如,本文讨论了如何使用增量lambda演算对自维护数据库视图进行建模。在这种情况下,我们的值将表示关系,而更改将表示这些关系的更新。通过将更改从简单关系传播到计算关系,我们有望优化维护视图的方式。这是数据库开发人员一直在触发器中实现的东西,但是能够从视图本身的定义派生更新不是很棒吗?

然而,正如我已经暗示的那样,我对这个想法的另一种应用很感兴趣--递增地更新DOM。

通常,这建议了一种不同的方式来处理命令性的、突变较多的API-首先,对我们打算通过该API应用的更改进行建模,并纯粹使用域的某些概念表示的更改结构来对它们进行建模。接下来,编写一个可以解释变化结构的解释器,最后使用增量λ演算实现从简化模型中的变化到真实世界中的变化之间的连接。

下面是一个简单的数据结构,它对DOM元素建模,就像我们可能在虚拟DOM库中找到的那样:

Newtype View=View{Element::String,Text::Atom String,attrs::IMAP String(原子字符串),Handles::IMAP String(原子EventListener),KIDS::IArray View}。

一个View由一个元素名称(如div、img等)、一些文本内容、一个属性映射、一个事件处理程序映射和一个子数组组成。

现在,下面是该类型的更改结构,它是根据唱片标签中类型的更改结构天真地派生出来的:

Newtype ViewChanges=ViewChanges{Text::Last String,attrs::MapChanges String(原子串)(Last String),处理程序::MapChanges String(原子EventListener)(Last EventListener),Child::Array(ArrayChange View ViewChanges)}实例patchView::PatchView ViewChanges。

文本::Jet(原子字符串)->;Jet视图元素::String->;Jet(IMAP字符串(原子字符串)->;Jet(IMAP字符串(原子EventListener))->;Jet(IArray视图)->;Jet视图。

现在,基本的应用程序循环很容易实现。组件可以由两个参数(模型和响应模型更改的回调)的JET函数来描述:

类型组件模型=Jet(原子(change model->;EventListener))->;Jet模型->;Jet视图。

首先,我们在常规模式下运行此函数,传入初始模型以呈现初始视图。然后,我们正常地将该视图呈现给DOM。

类型更改模型&>;EventListener的函数接受视图生成的更改,并将它们应用于当前模型以获得视图更改。如果我们可以编写一个解释器,将ViewChanges更改结构转换为实际的视图更改,那么我们就可以更新DOM以响应用户事件。

这个解释器就是我的纯脚本权限库所提供的。它是DOM更改结构的基本实现,可以解释为对DOM的实际更改。有了这一点,我们可以实现各种不同的抽象来构建将增量更改传播到DOM的UI。

我最喜欢这种方法的一点是,它确实迫使您在开始实现组件之前考虑您计划应用于组件的更改。此外,这些选择反映了在某些虚拟DOM实现中隐含的各种差异算法的权衡。例如,我应该使用IMAP还是IArray?";类似于在键控和非键控实现之间进行选择。

增量λ演算解决了我在上一篇博客文章中概述的虚拟DOM的问题。具体地说,我们保留了虚拟DOM给我们的清晰表示-我们的组件仍然只是函数,但现在是增量函数-我们通过省略虚拟DOM所需的区分步骤来简化操作语义,而是直接传播更改。

它还解决了我上面列出的静态DOM&34;方法的问题。具体而言:

我们根本不需要事件系统,所以事件不会爆炸。

最有趣的是,我们不需要为了应用一个小的改变而物化整个模型。我们只处理更改,而这些更改是普通的旧数据结构,可以很容易地通过网络发送,从而允许我们将视图逻辑与视图呈现分离。

在实践中,如果您已经习惯了虚拟DOM方法,则需要一些时间才能习惯这种方法,但是我们正在处理普通的老式函数,这使得入门和构建实际组件变得足够简单。