JavaScript中的可扩展性扩展机制

大家好!

我们提醒您,不久前,我们发布了传奇性书籍“ Expressive JavaScript ”(口才JavaScript)的第三版-首次以俄语印刷,尽管在互联网上可以找到以前版本的高质量翻译。



但是,JavaScript和Haverbeke先生的研究工作当然都不会停滞不前。 延续表达JavaScript的主题,我们提供了有关扩展设计的文章的翻译(以文本编辑器的开发为例),该文章于2019年8月下旬发布在作者的博客上


如今,以许多单独的软件包的形式构造大型系统已经成为一种时尚。 这种方法的驱动思想是,最好不要通过实现功能将人员限制为特定功能(由您提出),而应将此功能作为单独的程序包提供,供人与基本系统程序包一起下载。
一般来说,要做到这一点,您将需要...

  • 甚至不加载不需要的功能的能力,这在使用客户端系统时特别有用。
  • 可以用另一种实现替换不满足您需求的功能。 这样,也降低了核模块上的压力,否则将不得不覆盖各种实际情况。
  • 在实际条件下检查内核接口-通过在面向客户端的接口之上实现基本功能; 您必须使界面足够强大以至少应对这些功能的支持-并确保可以从第三方代码中组装功能相似的元素。
  • 系统组件之间的隔离。 项目参与者将需要寻找他们感兴趣的特定软件包。 可以对软件包进行版本控制,不推荐使用或替换其他软件包,所有这些都不会影响内核。


这种方法涉及一定的成本,这归结为额外的复杂性。 为了使用户能够上手,您可以为他们提供一个包含所有内容的包装程序包,但是在某些时候,他们可能必须删除该包装程序并自行处理辅助程序包的安装和配置,事实证明,这比包括整体库中提供的新功能。
在本文中,我将尝试探索各种设计可扩展性机制的方法,这些机制涉及“大规模可扩展性”,并立即为将来的扩展奠定新点。

可扩展性

从可扩展系统中我们需要什么? 首先,当然,您需要具有在外部代码上构建新行为的能力。

但是,这还不够。 让我离题,告诉你我曾经遇到的一个愚蠢的问题。 我正在开发文本编辑器。 在代码编辑器的一个早期版本中客户端可以指定特定行外观。 很棒-用户可以通过这种方式有选择地选择该行。

另外,如果您尝试从两个相互独立的代码片段开始进行排列,那么它们将开始彼此紧追。 适用于特定行的第二个扩展名将覆盖通过第一个扩展名所做的更改。 或者,如果稍后我们试图通过第一个代码删除在其帮助下进行的设计更改,则结果将覆盖由第二个代码段进行的设计。

解决方案是允许添加 (和删除 )代码,而不是安装代码,以便两个扩展可以与同一行交互,而不会中断彼此的工作。

在更笼统的表述中,有必要确保扩展可以组合在一起,即使它们彼此“完全不知道”,并且在交互过程中也不会发生冲突。

为此,每个扩展必须一次暴露给任意数量的代理。 根据具体情况,将如何处理每种效果。 以下是一些可能会有用的策略:

  • 所有更改均生效。 例如,如果我们向元素添加CSS类或在文本中的给定位置显示小部件,则可以立即完成这两项操作。 当然,将需要某种排序:小部件应以可预测且定义明确的顺序显示。
  • 更改在管道中排列。 此方法的一个示例是处理程序,该处理程序可以在对文档所做的更改生效之前过滤它们。 每个处理程序都会收到前一个处理程序所做的更改,后续处理程序可以继续进行此类修改。 这里的顺序并不重要,但可能很重要。
  • 先到先得的方法。 例如,此方法适用于事件处理程序。 每个处理程序都有机会修改事件,直到其中一个处理程序宣布所有操作都已完成,然后下一个处理程序又不会受到干扰。
  • 有时仅需要选择一个值即可,例如,确定特定配置参数的值。 在这里,使用某种运算符(例如,逻辑或,逻辑以及最小,最大)将潜在的输入减少为单个值是适当的。 例如,如果至少有一个扩展名需要,编辑器可以切换到只读模式,或者文档的最大长度可以是为此选项提供的所有值中的最小值。


在许多这种情况下,顺序很重要。 在这里,我的意思是遵守效果施加的顺序,该顺序必须是可控的和可预测的。

这是命令式扩展系统通常不是很好的情况之一,其操作取决于副作用。 例如, addEventListener DOM模型的addEventListener操作要求以事件处理程序的注册顺序调用事件处理程序。 如果所有呼叫都由一个系统控制,或者呼叫的顺序不是那么重要,这是正常的。 但是,如果您有许多彼此独立添加处理程序的软件组件,则很难预测首先将调用哪些处理程序。

简单的方法

让我举一个具体的例子:在开发ProseMirror(一种用于编辑富文本的系统)时,我首先应用了这种模块化策略。 该系统的核心本质上是无用的:它依赖于附加的包来描述文档的结构,绑定键,维护取消的历史记录。 尽管使用该系统确实有点困难,但它已在需要配置经典编辑器不支持的功能的程序中找到了应用程序。

ProseMirror扩展机制相对简单。 创建编辑器时,在客户端代码中指定连接对象的单个数组。 这些插件对象中的每一个都可以影响编辑器的各个方面,并且可以执行诸如添加状态数据位或处理接口事件之类的操作。
所有这些方面均设计为使用上述策略之一与配置值的有序数组一起使用。 例如,当您需要指定许多带值的字典时,后面扩展实例进行键绑定的优先级取决于您指定这些实例的顺序。 按键绑定的第一个扩展,知道如何处理此按键,即可对其进行处理。

通常,这种机制非常强大,他们可以利用它来发挥自己的优势。 但是,扩展系统迟早会变得如此复杂,以致于使用起来不方便。

  • 如果插件有很多效果,您只能希望它们相对于其他插件都需要相同的优先级,或者您必须将它们分成较小的插件才能正确排列它们的顺序。
  • 通常,组织插件变得非常谨慎,因为最终用户并不总是清楚哪些插件可以在优先级更高的情况下干扰其他插件。 在这种情况下所犯的错误通常仅在使用特定机会时在运行时发生,因此很容易忽略。
  • 如果插件是在其他插件的基础上构建的,则应记录这一事实,并希望用户不要忘记包括适当的依赖项(在必要时在该排序步骤)。


CodeMirror 版本6是同名代码编辑器的重写版本。 在这个项目中,我尝试开发一种模块化方法。 为此,我需要一个更具表现力的扩展系统。 让我们讨论设计这样的系统时必须应对的一些挑战。

订购方式

设计系统很容易,您可以完全控制扩展程序的顺序。 但是,要设计一个易于使用且同时允许您组合各种扩展代码的系统而又无需“现在就动手”类别进行大量干预的系统,要设计这样的系统要困难得多。
当涉及到排序时,我真的很想诉诸于优先级值。 例如,CSS z-index属性指示此元素在堆栈深度中占据的位置数。

如您在有时在样式表中发现的z-index值异常大的示例中所看到的那样,这种指示优先级的方法是有问题的。 模块本身不知道其他模块具有哪些优先级值。 选项只是未区分数值范围中间的点。 您可以设置一个巨大的值(或非常负的值)以尝试达到此范围的远端,但是其余工作归结为算命。

如果您定义了一组有限的明确定义的优先级类别,则可以稍微改善这种情况,以便扩展可以描述其优先级的一般“级别”。 另外,您将需要某种方法来打破类别之间的联系。

分组和重复数据删除

正如我上面提到的,一旦您开始严重依赖扩展,可能会出现某些扩展在工作时会使用其他扩展的情况。 如果您手动管理依赖关系,则这种方法无法很好地扩展。 因此,如果您可以同时提取一组扩展名,那就太好了。

但是,这种方法不仅进一步加剧了优先级问题,而且还引入了另一个问题:许多其他扩展都可以依赖于特定扩展,并且如果扩展以值的形式显示,则很可能同一扩展会被加载多次。 。 对于某些类型的扩展,例如事件处理程序,这是正常的。 在其他情况下,例如具有取消历史记录和工具提示库,此方法将很浪费,甚至可能破坏所有内容。

因此,如果我们允许扩展的布局,则这会给我们的与依赖项管理相关的系统带来一些额外的复杂性。 您必须能够识别不应重复的扩展名,并一次下载一个。

但是,由于在大多数情况下可以配置扩展,因此并非同一扩展的所有实例都完全相同,因此我们不能仅使用一个实例并对其进行处理。 我们将不得不考虑对此类实例进行有意义的合并(如果无法进行我们感兴趣的合并,则将报告错误)。

专案

在这里,我将大体描述我们在CodeMirror 6中的工作。这只是一个草图,而不是失败的解决方案。 库稳定后,该系统可能会进一步发展。

这种方法中使用的主要原语称为行为。 行为只是可以在扩展程序上构建的功能,可以为其指定值。 一个示例是状态字段的行为,其中,在扩展的帮助下,您可以添加新字段,并提供对该字段的描述。 另一个例子是浏览器中事件处理程序的行为。 在这种情况下,借助扩展,我们可以添加自己的处理程序。

从行为使用者的角度来看,在编辑器的特定实例中以某种方式配置的行为本身会给出有序的值序列,而之前的那些值具有更高的优先级。 每个行为都有一个类型,为此提供的值必须与该类型匹配。
行为表示为一个值,该值既用于声明行为的实例,又用于访问行为具有的值。 库中有许多内置行为,但是外部代码可以定义自己的行为。 例如,在定义行号之间的间隔的扩展名中,可以定义行为,该行为允许另一个代码在此间隔中添加其他标记。

扩展名是在配置编辑器时可以使用的值。 此类值的数组在初始化期间传递。 每个扩展名允许零个或多个行为。

可以将这种简单的扩展视为行为的实例。 如果我们为行为指定一个值,那么代码将向我们返回生成此行为的扩展名的值。

扩展名序列也可以分组为单个扩展名。 例如,在用于特定编程语言的编辑器配置中,您可以添加其他几个扩展名-例如,用于语法分析和突出显示文本的语法,有关必需缩进的信息,自动完成源,它将正确显示使用该语言完成行的提示。 因此,可以进行单个语言扩展,在其中我们只需收集所有这些对应的扩展并将它们分组在一起,就可以得到一个单一的值。

创建这样一个系统的简单版本,我们可以通过简单地将所有嵌套的扩展对齐到行为扩展数组中来停止此操作。 然后可以将它们按行为类型分组,然后构建行为值的有序序列。

但是,仍然需要处理重复数据删除并提供对顺序的更好控制。

与第三种类型相关的扩展的值(唯一扩展)仅有助于实现重复数据删除。 不应在同一编辑器中实例化两次的扩展属于这种类型。 要定义这样的扩展,您需要指定spec-type ,即扩展构造函数期望的配置值的类型,还需要指定实例化函数该实例化函数将获取一个由这些指定值组成的数组并返回扩展。

独特的扩展使将一组扩展解析为一组行为的过程变得复杂。 如果在对齐的扩展集中有唯一的扩展,则解析机制应选择唯一的扩展的类型,收集其所有实例并调用相应的实例化函数以及规范,然后将其全部替换为结果(在一个副本中)。

(还有一个陷阱:它们必须以正确的顺序解析。如果您首先启用唯一的扩展名X,但是由于解析而又得到另一个X,那么这将是错误的,因为必须将X的所有实例放在一起。由于实例化功能扩展是干净的,系统通过反复试验来应对这种情况,重新启动过程并记录有关在这种情况下可能学习的内容的信息。)

最后,您需要使用以下规则解决问题。 基本方法保持不变:保持扩展的提出顺序。 复合扩展名在它们发生的位置按相同顺序对齐。 首次打开时,将插入解析唯一扩展名的结果。

但是,扩展名可以将其某些子扩展名与具有不同优先级的类别相关联。 系统提供了四个此类类别: 后备 (在发生其他事情后生效), 默认 (默认), 扩展 (比批量优先级高)和覆盖 (可能应该排在第一位)。 在实践中,首先按类别进行排序,然后按起始位置进行排序。

因此,具有低优先级的键绑定扩展和具有常规优先级的事件处理程序是基于它们的,从具有键降级优先级和实例的键绑定扩展(不需要知道其构成的行为)的结果构建复合扩展是一种时尚。与事件处理程序的行为。

这种方法使您可以组合扩展而不用考虑扩展的“内部”功能,这似乎是一个了不起的成就。 我们在本文前面建模的扩展包括两个解析系统,它们在语法级别上表现出相同的行为,语法突出显示服务,智能缩进服务,取消历史记录,行距服务,自动闭合括号,键绑定和多项选择-全部运作良好。

用户必须学习一些新概念才能使用该系统。 另外,使用这样的系统确实比JavaScript社区中采用的传统命令式系统要复杂一些(我们称之为添加/删除效果的方法)。 但是,如果对扩展进行了适当的安排,那么这样做的好处将超过相关的成本。

Source: https://habr.com/ru/post/zh-CN468219/


All Articles