JavaScript中的可扩展扩展

哈Ha!

我们引起您的注意是期待已久的额外书本“ Expressive JavaScript ”,该书刚从印刷厂发行。


对于那些不熟悉本书作者工作的人(就所有百科全书本性而言,初学者也将喜欢它)-我们建议您熟悉他博客中的文章; 本文概述了有关组织JavaScript扩展的想法。

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

  • 不下载不需要的功能的功能在客户端系统上特别有用。
  • 可以用另一种实现替换不符合您目标的功能。 因此,内核模块上的负载也减少了-不需要借助它们来涵盖所有可能的实际情况。
  • 在实际条件下检查内核接口-通过在面向客户端的接口之上实现基本功能,您必须使该接口至少强大到可以支持这些功能。 因此,您可以确保可以在其上构建第三方开发人员编写的类似内容。
  • 系统各部分之间的隔离。 项目参与者可以简单地搜索他们感兴趣的软件包。 可以对软件包进行版本控制,将其标记为不需要的软件包,也可以在不影响核心代码的情况下对其进行替换。

这种方法通常会增加复杂性。 为了使用户更容易入门,您可以为他们提供一个包含“所有内容”的包装程序包,但是他们迟早可能会摆脱此shell并安装和配置特定的辅助程序包,有时这比切换到整体库中的另一个功能。

在本文中,我们将尝试讨论支持“大规模可扩展性”的扩展机制的选项,并允许您在未提供扩展点的地方创建新的扩展点。

可扩展性


我们想从可扩展系统中得到什么? 首先,当然,它应该具有使用外部代码扩展自身功能的能力。

但这还不够。 让我谈谈我曾经遇到的一个愚蠢的问题。 我正在开发代码编辑器。 在此编辑器的较早版本中,您可以在客户端代码中为特定行设置样式。 很棒-选择性的线路布局。

除了试图从代码的两个部分立即进行以更改行的外观的情况之外,这些尝试都开始对接。 应用于该行的第二个扩展名将覆盖第一个扩展名的样式。 或者,当第一个代码试图删除它在代码的另一部分中所做的设计时,它将覆盖第二个代码片段引入的样式。

我们设法找到了一个解决方案,提供了添加(和删除)扩展名的能力,而不是对其进行定义,从而使两个扩展名可以与同一行交互而不破坏彼此的工作。

从更一般的意义上讲,即使扩展完全不知道彼此的存在,也必须确保扩展可以组合在一起,而不会引起扩展之间的冲突。

为此,您需要确保任何数量的参与者都可以影响每个扩展点。 如何精确处理多种效果取决于情况。 以下是一些可能有用的方法:

  • 它们全部生效。 例如,当将CSS类添加到元素中或显示小部件时,这两个功能将同时添加。 通常,它们仍必须以某种方式进行排序:小部件应以可预测的,定义良好的顺序显示。
  • 它们以传送带的形式排列。 一个示例是一个处理程序,该处理程序可以在进行更改之前过滤添加到文档中的更改。 每次更改都首先馈给处理程序,该处理程序进而可以更改它。 在这种情况下,排序并不重要,但可以有所作为。
  • 您可以将“先到先得”的方法应用于事件处理程序。 每个处理程序都有机会为该事件提供服务,直到其中一个人说已经处理好该事件为止,此后,不再审问位于该队列后面的处理程序。
  • 碰巧您确实需要选择一个特定的值-例如,确定特定配置参数的值。 建议使用某个运算符(例如,逻辑和,逻辑或最小或最大)来限制一个位置的输入值数量。 例如,如果有任何扩展名命令,则编辑器可能会切换到只读模式。 您既可以设置文档的最大值,也可以设置报告给此选项的最小数量的值。

在许多情况下,顺序很重要。 这意味着应用效果的优先级应该是可控的和可预测的。

在这一方面,基于副作用的命令式扩展系统通常无法应对。 例如,浏览器DOM模型执行的addEventListener操作使事件处理程序完全按照注册它们的顺序进行调用。 如果所有调用都由单个系统控制,或者操作顺序确实不重要,这是正常的,但是,当您必须处理许多独立添加处理程序的软件片段时,很难预测首先要调用哪个程序。

简单的方法


举一个简单的例子:我首先对ProseMirror(一种用于编辑富文本的系统)应用了模块化策略。 原则上,该系统本身的核心是无用的-它完全依赖于描述文档结构,绑定键和取消历史记录的其他软件包。 尽管使用该系统确实有点困难,但已被需要自定义文本设计的产品采用,而经典编辑器中不提供此功能。

ProseMirror中使用的扩展机制相对简单。 创建编辑器时,客户端代码指示单个连接对象数组。 这些插件中的每一个都可以以某种方式影响编辑器的工作,例如,添加状态数据或处理接口事件。

所有这些方面均设计为使用上一部分中概述的策略与一系列配置值一起使用。 例如,当指定多个键分配时,键映射插件实例的指定顺序决定了它们的优先级。 知道如何处理它的第一个键映射会在处理中收到特定的键。

通常,此机制非常强大,并且被积极使用。 但是,在某个阶段,它变得复杂并且使用它变得不舒服。

  • 如果插件具有许多效果,那么您可以希望按该顺序将它们应用于其他插件,或者必须将它们分成较小的插件,以便可以正确地组织它们。
  • 通常,组织插件变得非常敏感,因为最终用户并不总是了解如果优先级更高的插件会影响其他插件的操作。 当使用特定功能时,所有错误通常仅在运行时出现-因此,很容易遗漏它们。
  • 基于其他插件的插件应记录这一事实-仍然希望用户不会忘记启用其依赖关系(以正确的顺序)。

版本6中的CodeMirror是重写的同名编辑器 。 在第六版中,我尝试开发一种模块化方法。 这需要一个更具表现力的扩展系统。 让我们看一下与设计这样的系统相关的一些挑战。

订购方式


设计一个可以完全控制扩展顺序的系统很容易。 但是,设计这样的系统非常困难,同时使用起来令人愉悦,并且无需大量而彻底的人工干预即可让您组合独立扩展的代码。

在订购时,它会拉出应用优先级值。 CSS z-index属性是一个类似的示例,该属性允许您设置一个数字,该数字指示该项目在堆栈中的深度。

由于样式表有时具有非常高的z-index值,因此很明显,这种表示优先级的方法是有问题的。 特定模块单独“不知道”哪个优先级值指示其他模块。 选项只是未定义数字范围内的点。 您可以指定过高的值(或极深的负值),以期达到此标尺的极限,但其他一切都是猜谜游戏。

通过定义一组有限的明确定义的优先级类别,可以稍微改善这种情况,以便可以按扩展名的近似“级别”对扩展进行分类。 但是您仍然必须以某种方式打破这些类别之间的联系。

分组和重复数据删除


如前所述,一旦您开始严重依赖扩展,可能会出现某些扩展会使用其他扩展的情况。 相互依赖性管理的伸缩性不好,因此,如果您可以一次提取一组扩展,那就太好了。

但是,不仅如此,在这种情况下,订购问题还会加剧。 另一个问题将会出现。 许多其他扩展可以一次依赖一个特定的扩展,如果将它们表示为值,则很可能出现多次下载同一扩展的情况。 在某些情况下,例如,在分配键或处理事件处理程序时,这是正常的。 在其他情况下,例如,当跟踪取消历史记录或使用工具提示库时,这种方法将浪费资源,并有破坏某些内容的风险。

因此,考虑到扩展的组成,我们被迫转移到扩展系统中与管理依赖项相关的复杂性部分。 您需要能够识别那些不应重复的扩展,并仅下载每个扩展的一个实例。

但是,由于在大多数情况下可以配置扩展,并且特定扩展的所有实例彼此之间都会有所不同,所以我们不能仅使用一个扩展实例并使用它-我们将必须以某种有意义的方式将其组合起来(或报告错误,当这不可能时)。

专案


在这里,我将描述在CodeMirror 6中所做的事情。我将此示例作为解决方案,而不是唯一的真正解决方案。 随着库的稳定,该系统有可能进一步发展。

这种方法的主要原语称为行为。 行为就是您可以通过指示值来扩展的那些东西。 例如,考虑状态字段的行为,在状态扩展的帮助下,您可以添加新字段,并提供每个字段的描述。 或基于浏览器的事件处理程序的行为,您可以在其中使用扩展名添加自己的处理程序。

从使用者行为的角度来看,在编辑器的特定实例中配置的那些行为给出了有序的值序列,其中优先级更高的值排在第一位。 每个行为都有一个类型,并且其值必须与该类型匹配。

行为表示为一个值,该值既用于声明行为的实例,又用于引用行为可能具有的值。 例如,定义行号背景的扩展名可以定义允许其他代码向此背景添加新标记的行为。

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

扩展的最简单类型是行为的实例。 通过为此行为设置一个值,我们可以得到实现此行为的扩展的值。
扩展名序列也可以分组为单个扩展名。 例如,在给定编程语言的编辑器配置中,可以拉出许多其他扩展,特别是用于解析和突出显示语言的语法,有关如何缩进的信息以及有关完成的信息源,该信息源可以智能地补充该语言的代码。 因此,您获得了语言扩展,该语言扩展简单地收集了提供累积值的所有必要扩展。

在描述这种系统的简单版本时,我们可以在此止步,将嵌套的扩展简单地放入行为的单个扩展数组中。 然后可以将它们按行为类型分组并获得行为值的有序序列。

但是,我们仍然没有弄清楚重复数据删除,因此我们需要对顺序进行更完全的控制。

第三类的值包括唯一的扩展名 ;这是确保重复数据删除的机制。 您不想在同一编辑器中实例化两次的扩展就是这样。 为了定义这样的扩展,指定了规范类型,即扩展构造函数期望的配置值的类型,以及实例化函数,该实例化函数获取此类规范值的数组并返回扩展。
独特的扩展使将一组扩展解析为一组行为的过程变得复杂。 只要在对齐的扩展集中存在唯一扩展,解析机制就应该选择唯一扩展的类型,收集其所有实例,使用其spec值调用实例化函数,并将其替换为结果(在一个实例中)。

(还有一个障碍-必须按特定顺序解析。如果您首先启用唯一的扩展名X,但是随后扩展名Y解析为另一个X,则将是一个错误,因为必须将X的所有实例组合在一起。由于实例化扩展是纯操作,面对它的系统将通过反复试验来执行它,重新启动过程-并记录澄清的信息。)
最后,让我们谈谈优先级。 在这种情况下,基本方法是保持报告扩展的顺序。 复合扩展名会对齐并按顺序准确地在它们初次碰到的位置处。 解析唯一扩展名的结果也将插入在其首次出现的位置。

但是扩展可以将其某些子扩展分配给具有不同优先级的类别。 系统确定此类的类型:默认情况下,回滚(在发生其他事情之后生效),扩展(比批量优先级高)和重新定义(也许应该位于最顶部)。 实际订购首先按类别进行,然后按起始位置进行。

因此,具有低优先级键分配的扩展和具有正常优先级的事件处理程序可以为我们提供基于具有键分配的扩展构建的复合扩展(在这种情况下,您无需知道优先级包括“回滚”和行为实例的行为)事件处理程序。

主要成就似乎是我们已经获得了组合扩展的能力,而不管其中每个扩展都做了什么。 到目前为止,我们已经在扩展中建模,其中:具有相同语法行为,语法突出显示,智能缩进服务,取消历史记录,行号背景,括号自动关闭,键分配和多项选择的两个解析系统-一切正常。

要使用这样的系统,您确实必须掌握几个新概念,并且它肯定比JavaScript社区中接受的传统命令式系统复杂(调用添加/删除效果的方法)。 但是,正确链接扩展名的能力似乎可以证明这些费用是合理的。

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


All Articles