使用 JEEP(一个全新的 JavaScript 框架)构建层级可视化工具
本文介绍了 JEEP,一个旨在使 JavaScript 更健壮的新 OOP 框架,并通过教程风格构建一个简单的工具来展示其强大性和灵活性。
引言
注意:JEEP 并不是另一个单页应用程序框架,也不是又一个模仿 jQuery 样式的实用工具库。它是一个更深层次的框架,解决了 JavaScript 语言本身的特性,可能与您习惯的不同。最好以开放的心态阅读本文。
JEEP 是一个雄心勃勃的框架,旨在为 JavaScript 带来超越原生语言的面向对象特性,从而实现健壮的软件工程。与纯 JavaScript 代码相比,Jeep 易于创建复杂结构和行为的可重用、可自定义和可扩展的组件。它是一个受 C++ 启发的框架,试图在大部分情况下让 JavaScript 看起来、感觉和行为都像 C++,但请放心,它只借鉴了最好的特性。
Jeep 拥有众多特性,它们都围绕结构和语义。Jeep 是一个相当庞大、复杂且全面的框架,这里展示的只是冰山一角,而且是积极意义上的。您需要阅读 github 仓库中超过 130 页的文档,其中我详细讨论了框架的每一个方面,才能完全理解 Jeep。为简单起见,特性归纳为以下两个列表。
定性特性
- 严格执行语法和语义规则,提高代码健壮性
- 提倡编写直观、易读且易于扩展的代码
- 允许代码高度结构化和组织化
- 提高生产力和性能
技术特性
- 提供一系列对象,帮助恰当地建模数据和行为
- 允许成员具有
public
、protected
和private
访问限制 - 允许成员变量和函数是常量
- 允许对函数进行一系列验证,例如参数类型、数量等
- 允许单重继承和多重继承,支持
virtual
和abstract
函数 - 提供开发模式和生产模式(例如调试和发布构建)
- 以及更多
为演示目的,我们创建了一个小型层级可视化工具。该层级旨在表示一次头脑风暴会议。该工具实际上是 Jeep 手册示例章节中所示工具的一个简化版本。该工具的创建是为了帮助我可视化类层级并推理其正确性。
为了展示使用 Jeep 的优势,我们将代码与纯 JavaScript 代码进行比较和对比。我将简要介绍我们在过程中遇到的 Jeep 的不同方面。
应用程序
UI 包含五个组件——图表、脚本框、脚本工具栏、脚本列表和图例。脚本列表是您典型的资源管理器 UI。按钮的功能顾名思义;Draw 仅绘制,Add 绘制并将脚本添加到列表中,Update 绘制并更新当前脚本,Remove 删除当前脚本。Ctrl+Enter
组合键等同于单击 **Draw** 按钮。
脚本非常简单,功能有限,因为这是一个示例应用程序。通用模式是 parent[child1, child2, ...]
,其中每个条目都是框中显示的名称。名称不得包含空格,而是使用连字符,连字符将被转换为空格。
名称本身遵循 <nudge><root><dup>name<args>
模式,其中尖括号内的所有内容都是可选的。
nudge
是一个或多个加号,用于在空间不足时将框向下(或根据方向向右)移动。每个加号都会将框移动一个固定的步长。root
是一个单独的美元字符,表示该框将是一个根框。可以有多个根。dup
是一个单独的点,允许具有相同名称的多个框。框和名称是一对一对应的。出现多次的名称引用同一个框,因此这对于灵活性是必需的。但是,您在创建重复名称后无法引用它们,因为每个重复名称都会生成一个不同的框。args
是一系列用正斜杠分隔的额外信息,称为参数。第一个参数始终是背景颜色,第二个参数始终是文本颜色。颜色是 RGB 值,以 6 位十六进制数字表示。第三个是单个字符,可以是以下之一,并为框设置关联的属性。- v = 非常好
- g = 好
- n = 值得考虑
- x = 坏,应丢弃
如果设置了有效的属性,应用程序将覆盖其他参数设置的颜色。参数可以为空,但必须保持顺序。因此,如果您只想设置属性,则必须有三个正斜杠,属性出现在最后。
由于重复的名称始终引用该名称创建的第一个框,因此稍后设置的颜色和属性将覆盖早期设置的颜色和属性。
请注意,不是根且没有父项的名称将不会出现在图表中,尽管它会在内部创建。
您可以尝试修改脚本,但请记住这只是一个示例,功能有限。需要加号就是一个例子——我不想实现复杂的对齐逻辑,所以对齐的负担落在用户身上。
实现
请注意,Jeep 本质上是 JavaScript,因此实现细节无关紧要,因为相同的细节可以用于纯 JavaScript 风格的编码。Jeep 存在的目的是为 JavaScript 代码提供组织和健壮性,因此这里只演示这些方面。需要明确的是,这里展示的不是工具如何实现其功能,而是整体代码在架构层面的样子。
UI 由用于绘图的画布和各种 HTML 元素组成。
面向函数的 JavaScript 代码
最简单的代码看起来是这样的
function drawHierarchy(canvas, script, orientation){}
最大的问题在于,函数必须封装所有实现细节。它使用的所有辅助函数,用于对齐等,都必须在函数内部,以避免从外部意外使用。这显然会带来性能开销。如果您将这些函数移到全局作用域,您不仅会面临意外使用的风险,还会面临意外覆盖的风险。您需要创建一个足够随机名称的辅助对象,并将细节移到其中。这种方法在组织上已经是一个噩梦了。
此外,在功能方面,这种代码是不可扩展的。稍加思考就会发现,层级可以变成一个通用组件。唯一区分不同层级的只是参数的含义,除了位于第一个和第二个位置的颜色参数。参数就是用不同的颜色给框上色。
考虑到这一点,函数需要升级成这样的
function drawHierarchy(canvas, script, orientation, colorMap){}
其中 colorMap
参数可以是一个键值对,键是参数,值是相关的颜色。该函数将被这样调用
drawHierarchy(document.getElementById("diagram”), scriptBox.value,
getHierarchyOrientation(), {
v: {color: "#aa0000", txcolor: "white"},
x: {color: "gray", txcolor: "white"}
})
只要您只进行着色,函数就能正常工作。如果您想要一些鼠标交互呢?好吧,您可以将函数升级成这样
function drawHierarchy(canvas, script, orientation, colorMap, actionMap){}
其中 actionMap
参数可以是一个键值对,键是鼠标事件名称,值是相关的处理程序。现在,预见到这种未来的扩展,您可以将函数更改为如下所示
function drawHierarchy(canvas, script, orientation, options){}
其中 options
参数是一个对象,您可以将颜色、操作和其他映射移入其中,如下所示:{colorMap: {}, actionMap: {}}
。
虽然这是完成任务的一种可接受的方式,但该函数存在注入问题——您必须在各处进行依赖注入才能使其作为通用组件正常工作。可以想象一下,双击一个框时弹出窗口,同时高亮显示所有相关的框。好吧,这没那么糟糕,因为纯 JavaScript 代码一直在创建比这更复杂的行为的应用程序,但关键是有一个更好的方法。
面向对象的 JavaScript 代码
创建可重用组件的显而易见的方法是将其制成一个类。请注意,我从未在新 JavaScript 类语法中使用过,所以现在此处也不打算使用,而旧的原型样式可能对大多数人来说有些刺眼,而且我对此也不熟悉,所以也不会展示。基本上,我不会在这部分展示任何代码。
使用 JavaScript 类,创建具有不同外观和行为的各种层级变得非常容易,然而这种方法由于使用了有缺陷且不完美的面向对象机制而存在三个重要问题。
首先,实现细节仍然可能被意外使用。您必须要么创建一个 private
对象,如前所述,要么使用命名约定,例如小写开头的名称是 private
,并记录它们,希望程序员阅读并遵守。
其次,如果派生类中的成员名称与基类名称冲突但代表不同的对象,会发生什么?这会静默失败,但不会持续太久,但关键是其有缺陷且不完美。
第三,也是最重要的,如果您需要使层级可序列化怎么办?很可能有一个可序列化的类,必须扩展它才能使派生类可序列化。然而,在这种情况下,这种情况无法发生,因为 JavaScript 不提供多重继承,而 mixins 也很糟糕,您将被迫使用组合或依赖注入,这不适合自然的使用模式或有用性。例如,假设应用程序有一个可序列化对象的数组,通过调用适当的成员函数来处理。可序列化层级的实例无法获得此好处,必须单独处理。
使用 JEEP 编码
与纯 JavaScript 代码相比,使用 Jeep 编写的代码在所有方面都更优越,并且解决了上述所有问题和棘手情况。Jeep 提供的面向对象机制比 JavaScript 原生功能更全面、更健壮。
应用程序组件被建模为 Jeep 类。我们需要两个类,一个用于层级,一个用于画布。画布绘制本身非常通用,使用一个抽象了大部分底层细节(如上下文)的类而不是到处重复它们是有意义的。
处理画布绘制的 Painter
类看起来是这样的。
RegisterClassDef("Painter", {
CONSTRUCTOR: function(canvas){
this.canvasElement = canvas || document.createElement("canvas");
this.ctx = this.canvasElement.getContext('2d');
},
PUBLIC: {
Reset: function(){},
Scale: function(x, y){},
DrawLine: function(line){},
DrawRectangle: function(rect){},
DrawText: function(text){},
GetTextWidth: function(text){},
},
PRIVATE: {
ctx: null,
canvasElement__get: null,
saveProps: function(){},
restoreProps: function(){},
// more
}
})
请注意代码多么直观和易读。我希望您已经猜到了这些含义,但我仍然会简要解释,因为这是我作为作者的职责。
类或 Jeep 提供的任何对象都必须在实例化之前定义。定义生成有两种类型——创建和注册。第一种方法返回一个局部于作用域的对象,而后者使定义对所有应用程序代码全局可用。通用定义显然需要注册。RegisterClassDef
函数实际上是几个对象的一个成员,而不是像这里看起来那样是一个自由函数,但为了简洁起见,这里不讨论那部分。
第一个参数是名称,第二个参数是声明。根据给定的声明生成定义,并且只有在它符合语法和语义时才会生成;否则,Jeep 将中止并显示一系列错误消息,详细说明声明的无效性。Jeep 提供的所有对象都遵循类似的语法,但声明会因明显的原因而有所不同。
public
成员可以被所有代码访问,private
成员只能被自己的成员函数访问。从任何其他代码尝试访问 private
成员都会生成运行时错误。构造函数是一个特殊函数,在实例化时首先调用;它不能通过任何其他方式显式调用。默认情况下,它是 public
。
奇怪的后缀 _get
称为指令,成员变量和函数都可以有一个或多个。指令会被处理掉,不作为名称的一部分保留。指令实际上是 Jeep 的关键字,用于修改行为或影响代码生成。由于 Jeep 实际上是 JavaScript 而不是一门独立的编程语言,它不能发明带有空格的语法,因此它进行即兴创作。这个特定的指令会自动生成一个 public
函数,该函数返回命名变量。这为您节省了大量的样板代码。
point
、line
和 text
函数的参数是记录实例。记录对象是纯数据对象,充当应用程序的构建块,不包含构造函数等函数。关联的记录如下所示。矩形和文本记录有共同之处,因此可以从一个共同的记录扩展。
let Item =CreateRecordDef("Item", {
x: 0,
y: 0,
})
RegisterRecordDef("Line", {
xa: 0,
ya: 0,
xb: 0,
yb: 0,
color: ""
})
RegisterRecordDef("Rectangle", {
EXTENDS: [Item],
width: 0,
height: 0,
fillColor: "",
lineColor: "",
})
RegisterRecordDef("Text", {
EXTENDS: [Item],
content: "",
font: "14pt Verdana",
color: "black",
align: "center",
})
对于任何对象,在声明中给变量赋的值是预期的默认值。实例成员将具有这些值,除非构造函数修改了它们,或者实例化时使用了不同的值。实例化通过定义对象属性的 New
和 InitNew
函数进行。前者使用默认值实例化,后者使用作为参数提供的值实例化。对于后者,未指定的变量将采用默认值。
let t = Text.New()
let tt = Text.InitNew({color: "red”, align: "center”})
我希望您能看到使用 Jeep 创建和使用数据与纯 JavaScript 代码相比有多么容易。
层级由 Tree
类生成和绘制,定义如下
RegisterClassDef("Tree", {
CONSTRUCTOR: function(width, height){},
PUBLIC: {
Paint: function(painter, LayoutManagerDef){},
Reset: function(script, painter, LayoutManagerDef){},
PROTECTED: {
GetNodeColors__virtual: function(args){},
},
PRIVATE: {
root: null,
processName: function(n){},
// more
}
})
关于这个类有三点需要注意
- 绘制是使用 painter 实例完成的,而不是直接在画布上。函数调用者设置和管理实例。
- 通过一个
protected virtual
函数获取颜色,并向其提供参数。其重要性很快就会知道。 - 布局是通过策略模式而不是依赖注入模式实现的。策略由下面的类实现。
Protected 成员只能被类自己的成员及其派生类访问。Virtual 函数是实现多态的方式。如果您需要鼠标点击时的行为,可以添加一个适当的 virtual 函数并在基类的适当位置调用它。我希望您能意识到,与纯 JavaScript 代码相比,在 Jeep 中扩展行为非常简单,几乎是微不足道的。Virtual 函数最好设为 protected
,因为它们很可能只是实现细节。如果您不希望派生类调用基类的实现,则可以将其设为 private
。
LayoutBase
类建立了策略,它看起来是这样的
RegisterClassDef("LayoutBase", {
PUBLIC: {
SetupPositions__abstract: function(nodeArr, width, height){},
},
PROTECTED: {},
})
abstract
函数非常类似于 virtual
函数,只是它的存在强制派生类实现它。带有未实现 abstract
函数的类无法实例化;任何尝试都会生成运行时错误。布局的 abstract
函数以一种独立的、与节点内容或上下文无关的方式进行节点排列,它只是设置给定数组中每个条目的 x
和 y
坐标。这里给出的节点实际上与树内部维护的节点不同,树会在它们之间进行转换。这样做是因为布局是第三方代码,树不信任此类代码但仍必须处理它们。
两个布局通过这两个类实现
RegisterClassDef("VertLayout", {
EXTENDS: [LayoutBase],
CONSTRUCTOR: function(){},
PUBLIC: {
SetupPositions__virtual: function(nodeArr, width, height){},
},
})
RegisterClassDef("HorzLayout", {
EXTENDS: [LayoutBase],
CONSTRUCTOR: function(){},
PUBLIC: {
SetupPositions__virtual: function(nodeArr, width, height){},
},
})
至此,我们已经具备了构建应用程序的所有组件,但层级显示的是白色框和黑色文本,因为通用组件不知道应用程序特定的参数含义。因此,我们必须扩展树并适当地实现 virtual 函数。该类看起来是这样的
let AppTree = CreateClassDef("AppTree", {
EXTENDS: [TinyTreeLib.GetObjectDef("Tree")],
PROTECTED: {
GetNodeColors__virtual: function(args){},
},
STATIC: {
GetLegend: function(){},
},
});
Jeep 提供一个名为 library 的对象,顾名思义,它有助于整洁地组织代码,从而避免意外使用、名称冲突等。它还提供了一个方便的接口来声明、构建、初始化和访问。但是,为了简洁起见,我不会在这里讨论它们。到目前为止显示的所有对象都是多个库的成员。TinyTreeLib
是应用程序创建的用于存储检索到的库的变量,而不是由 Jeep 自动生成的。
静态成员是可以不实例化类即可访问的成员。它们也可以是 private
,但默认是 public
。图例生成不是实例特定的,因此将其设为 static
是合适的。
图例是使用结构创建的,结构是 Jeep 提供的一种轻量级类。结构旨在实现简单的实用程序,而类是应用程序需求的完整模型。结构具有类似于记录的声明,但具有类似于类的行为。它们的功能也受到限制,例如,您无法扩展它们。
管理图表的结构如下所示。构造函数接受标题以及颜色及其标签的键值对。图例可以动态修改,这有助于用户可以定义自己的含义和参数的应用程序。
RegisterStructDef("Legend", {
CONSTRUCTOR: function(msg, cmap){},
GetDOMElement: function(){},
Clear: function(){},
Append: function(cmap){},
// more
})
应用程序脚本将所有这些组件整合在一起,如下面的代码片段所示
canvas = document.createElement("canvas");
diagElem = document.getElementById("diagram");
diagElem.appendChild(canvas)
canvas.height = diagElem.offsetHeight;
canvas.width = diagElem.offsetWidth;
tree = AppTree.New(canvas.offsetWidth, canvas.offsetHeight)
Painter = TinyCanvasLib.GetObjectDef("Painter")
painter = Painter.New(canvas)
painter.Scale(canvas.width/canvas.width, canvas.width/canvas.height)
legend = AppTree.STATIC.GetLegend();
legend.style.position = "fixed";
legend.style.right = "5px";
legend.style.bottom = "5px";
legend.style.display = "none";
document.body.appendChild(legend);
function render(){
tree.Reset(scriptBox.value, painter, getSelectedLayout())
}
document.getElementById("draw-script").onclick = function(){
render();
}
结论
本文档的目的是展示在处理复杂度适中的应用程序时,使用 Jeep 编码在结构和行为上都优于使用纯 JavaScript 代码。
本文档的主要收获必须是这些。
- Jeep 提倡编写直观、易读且易于扩展的代码。
- Jeep 提供多种对象来帮助组织代码并对数据和行为进行建模。
- Jeep 提供机制来有效隐藏实现细节。
- Jeep 提供机制来有效实现多态行为。
这里展示的只是 Jeep 的一小部分功能。还有多重继承、常量函数、自动销毁机制等等,旨在使 JavaScript 代码健壮而强大。访问 github 仓库 了解更多信息。
Jeep 试图以最小的开销(有时甚至没有开销,具体取决于许多因素)提供所有这些功能。我希望您的兴趣足以让您阅读关于 Jeep 的信息并尝试使用它。