为什么我们选择 Clojure 编程语言用于 Penpot

尽管 Clojure 不是主流语言,但由于其关键特性:稳定性、向后兼容性和语法抽象,它是 Penpot 的正确选择。
1 位读者喜欢这篇文章。
Person using a laptop

“为什么选择 Clojure?”可能是我们在 Penpot 被问到最多的问题。我们在 FAQ 页面上有一个模糊的解释,所以在这篇文章中,我将解释我们决策背后的动机和精神。

这一切都始于一个 PIWEEK。当然!

在 2015 年的其中一个 个人创新周 (PIWEEK) 期间,一个小团队想出了创建一个开源原型工具的想法。他们立即开始工作,并在一个星期的辛勤工作(和许多乐趣)后发布了一个可用的原型。这是第一个静态原型,没有后端。

我不是最初团队的成员,但当时有很多理由选择 ClojureScript。在一个星期的黑客马拉松中构建原型并非易事,ClojureScript 当然对此有所帮助,但我认为选择它的最重要的原因是它很有趣。它提供了一种函数式范式(团队中,很多人对函数式语言很感兴趣)。

它还提供了一个完全交互式的开发环境。我不是指编译后浏览器自动刷新。我的意思是在运行时刷新代码,而无需刷新页面且不丢失状态!从技术上讲,你可以开发一个游戏,并在你还在玩游戏时更改游戏的行为,只需在编辑器中修改几行代码即可。使用 ClojureScript(和 Clojure),你不需要任何花哨的东西。语言结构从一开始就设计时考虑了热重载。

Image of the first version of UXBOX (Penpot's original concept) in 2016

(Andrey Antukh,CC BY-SA 4.0)

我知道,今天(2022 年),你也可以在普通的 JavaScript 上使用 React 获得类似的东西,也可能在使用我不太熟悉的其他框架中获得类似的东西。但话又说回来,这种能力支持也可能是有限且脆弱的,因为语言的内在局限性、封闭的模块、缺乏合适的 REPL。

关于 REPL

在其他动态语言中,如 JavaScript、Python、Groovy(等等),REPL 功能是作为事后才添加的。因此,它们通常在热重载方面存在问题。语言模式在实际代码中很好,但在 REPL 中不适用(例如,JavaScript 中的 const 在 REPL 中会评估相同的代码两次)。

这些 REPL 通常用于测试为 REPL 制作的代码片段。相比之下,Clojure 中 REPL 的使用很少涉及直接在 REPL 中键入或复制,更常见的是从实际源文件中评估小的代码片段。这些片段经常作为注释块留在代码库中,因此你可以在将来更改代码时再次在 REPL 中使用这些片段。

在 Clojure REPL 中,你可以开发整个应用程序而没有任何限制。Clojure REPL 的行为与编译器本身没有区别。你可以在已经运行的应用程序中对任何命名空间中的特定函数进行各种运行时内省和热替换。事实上,在生产环境中找到在本地套接字上暴露 REPL 的后端应用程序并不罕见,以便能够检查运行时,并在必要时修补特定函数,甚至无需重启服务。

从原型到可用的应用程序

在 2015 年 PIWEEK 之后,Juan de la Cruz(Penpot 的设计师,也是项目想法的最初作者)和我开始在业余时间从事该项目。我们使用从第一个原型中学到的所有经验教训重写了整个项目。在 2017 年初,我们内部发布了可以称为第二个功能原型的版本,这次带有一个后端。关键是,我们仍然在使用 Clojure 和 ClojureScript!

最初的原因仍然有效且相关,但对如此重要的时间投入的动机揭示了其他原因。这是一个很长的列表,但我认为最重要的特性是:稳定性、向后兼容性和语法抽象(以宏的形式)。

Image of the current Penpot interface

(Andrey Antukh,CC BY-SA 4.0)

稳定性和向后兼容性

稳定性和向后兼容性是 Clojure 语言最重要的目标之一。通常不会急于将所有流行的东西都包含到语言中,而没有测试其真正的用处。看到人们在 Clojure 编译器的 alpha 版本之上运行生产环境并不罕见,因为即使在 alpha 版本上也很少出现不稳定性问题。

在 Clojure 或 ClojureScript 中,如果一个库在一段时间内没有提交,那么它很可能已经很好了。它不需要进一步开发。它可以完美地工作,并且没有必要更改可以按预期运行的东西。相反,在 JavaScript 世界中,当你看到一个几个月没有提交的库时,你往往会感觉该库已被放弃或无人维护。

有很多次我下载了一个 6 个月未触及的 JavaScript 项目,却发现超过一半的代码已经过时且无人维护。在其他情况下,它甚至无法编译,因为某些依赖项没有遵守语义版本控制。

这就是为什么 Penpot 的每个依赖项都经过仔细选择,并考虑到连续性、稳定性和向后兼容性。其中许多是内部开发的。我们仅在第三方库被证明具有相同属性时,或者当内部完成的投入产出比不值得时,才委托给第三方库。

我认为一个好的总结是我们尝试拥有最少的必要外部依赖项。React 可能是大型外部依赖项的一个很好的例子。随着时间的推移,它表明他们真正关心向后兼容性。每个主要版本都逐步合并更改,并具有清晰的迁移路径,允许新旧代码共存。

语法抽象

我喜欢 Clojure 的另一个原因是其清晰的语法抽象(宏)。这是一项特性,作为一般规则,它可能是一把双刃剑。你必须小心使用它,不要滥用它。但是,由于 Penpot 项目的复杂性,拥有提取某些常见或冗长构造的能力帮助我们简化了代码。这些陈述无法概括,它们可能提供的价值必须根据具体情况而定。以下是一些对 Penpot 产生重大影响的重要实例

  • 当我们开始构建 Penpot 时,React 只有作为类的组件。但是这些组件在 rumext中被建模为函数和装饰器。当 React 发布带有 hooks 的版本,极大地增强了函数式组件时,我们只需要更改宏的实现,Penpot 90% 的组件就可以保持不变。随后,我们逐渐从装饰器完全过渡到 hooks,而无需费力的迁移。这强化了前面段落的相同想法:稳定性和向后兼容性。
  • 第二个最重要的案例是轻松使用原生语言构造(向量和映射)来定义虚拟 DOM 的结构,而不是使用类似 JSX 的自定义 DSL。使用这些原生语言构造将使宏最终在编译时生成对 React.createElement 的相应调用,同时仍然为其他优化留下空间。显然,语言是面向表达式的这一事实使这一切更符合语言习惯。

这是一个 JavaScript 中的简单示例,基于 React 文档中的示例

function MyComponent({isAuth, options}) {
    let button;
    if (isAuth) {
        button = <LogoutButton />;
    } else {
        button = <LoginButton />;
    }

    return (
        <div>
          {button}
          <ul>
            {Array(props.total).fill(1).map((el, i) =>
              <li key={i}>{{item + i}}</li>
            )}
          </ul>
        </div>
    );
}

这是 ClojureScript 中的等效示例

(defn my-component [{:keys [auth? options]}]
  [:div
   (if auth?
     [:& logout-button {}]
     [:& login-button {}])
   [:ul
    (for [[i item] (map-indexed vector options)]
      [:li {:key i} item])]])

用于表示虚拟 DOM 的所有这些数据结构都在编译时转换为相应的 React.createElement 调用。

Clojure 是如此以数据为导向,这一事实使得使用语言的相同原生数据结构来表示虚拟 DOM 成为一个自然而符合逻辑的过程。Clojure 是 LISP 的一种方言,其中语言的语法和 AST 使用相同的数据结构,并且可以使用相同的机制进行处理。

对我来说,通过 ClojureScript 使用 React 比在 JavaScript 中使用它感觉更自然。添加到 React 以使其舒适地使用的所有额外工具,例如 JSX、不可变数据结构或用于处理数据转换和状态处理的工具,都只是 ClojureScript 语言的一部分。

访客语言

最后,Clojure 和 ClojureScript 的基本原理之一是它们是作为访客语言构建的。也就是说,它们在现有平台或运行时之上工作。在这种情况下,Clojure 构建在 JVM 之上,而 ClojureScript 构建在 JavaScript 之上,这意味着语言和运行时之间的互操作性非常高效。这使我们能够利用 Clojure 的整个生态系统以及 Java 中完成的一切(ClojureScript 和 JavaScript 也是如此)。

还有一些代码片段在以命令式语言(如 Java 或 JavaScript)编写时更容易编写。Clojure 可以与它们在同一个代码库中共存,而不会出现任何问题。

前端和后端之间也易于共享代码,即使每个代码都可以在完全不同的运行时(JavaScript 和 JVM)中运行。对于 Penpot,几乎所有用于管理文件数据的最重要逻辑都是用代码编写并在前端和后端执行的。

也许你可以说我们选择了有些人称之为“无聊”的技术,但实际上它一点也不无聊。

权衡

显然,每个决定都有权衡。选择使用 Clojure 和 ClojureScript 也不例外。从商业角度来看,选择 Clojure 可能被视为有风险,因为它不是主流语言,与 Java 或 JavaScript 相比,它的社区相对较小,并且寻找开发人员天生就更复杂。

但在我看来,学习曲线远低于乍看起来的程度。一旦你摆脱了它与众不同的恐惧(或者我开玩笑地称之为:对括号的恐惧),你就会很快开始流利地使用该语言。有很多学习资源,包括书籍和培训课程。

我注意到的真正障碍是范式转变,而不是语言本身。对于 Penpot 而言,项目必要且固有的复杂性使得编程语言在我们面对开发时成为最不重要的问题:构建设计平台绝非易事。


本文最初发表在 Kaleidos 博客上,并已获得许可重新发布。

Andrey Profile Picture
软件工程师。Clojure 爱好者。对实用的函数式范式非常感兴趣。《ClojureScript Unraveled》一书的合著者。@funcool github 组织的成员。

评论已关闭。

© . All rights reserved.