红帽白皮书新鲜出炉!点击获取,让你的云战略更胜一筹! 了解详情
写点什么

函数式 UI:Web 开发终于摆脱了框架的束缚

  • 2020-02-26
  • 本文字数:8208 字

    阅读完需:约 27 分钟

函数式UI:Web开发终于摆脱了框架的束缚

本文要点


  • 用户界面都是响应式系统,由用户界面应用程序接收的事件与应用程序必须在接口系统上执行的动作之间的关系来定义

  • 流行的 UI 框架(如 React、Vue 或 Angular)通常具有很高的次生复杂性,它们的状态和效果零散地分散在一个组件树中,并且由上帝组件处理大量无关的问题

  • 函数式 UI 是用于用户界面应用程序的一组实现技术,它强调了应用程序的效果(effectful)和纯函数部分之间的明确界限

  • 函数式 UI 从概念上讲是很简单的,可以更直接地反映应用程序的规范(specification),将 UI 框架降级为一个单纯的库,允许开发人员对用户场景进行单元测试,并减少应用程序的设计和实现错误。

  • 函数式 UI 会针对正确性进行优化,同时会为开发人员创建一些选项,以便在将来获得更多信息时重新考虑诸如 UI 框架或远程数据获取机制之类的关键决策。

为什么要使用函数式 UI?

顾名思义,用户界面允许用户与其他系统交互,其理念是:相比直接与其他系统互动,这种交互界面会提供一些用户期望的好处。用户通过某种输入方式(例如按键或声音输入)表达意图,然后用户界面通过在接口系统上预定义的动作来做出响应。用户界面基本上是天然的响应式系统。用户界面的任何规范技术都必须详细说明用户界面输入和接口系统上的动作之间的对应关系,也就是应用程序的行为规范。这样一来,就可以根据用户发起或应用程序接受的一系列事件,以及系统对应的预期反应来定义一个用户故事。


许多用来实现用户界面的框架(Angular2、Vue 和 React 等)都使用回调过程或事件处理程序,后者会作为事件的结果而直接执行相应的动作。决定要执行哪个动作(例如输入验证、本地状态更新、错误处理或数据获取等),通常意味着要访问和更新某些状态,而这些状态并不总是在作用域内。因此框架会包含一些状态管理或通信能力,以处理所需的相关状态的传递,并在允许和要求时更新状态。


基于组件的用户界面实现往往包含一些状态,而动作以不明显的方式沿着组件树散布开来。例如,一个待办事项列表应用程序可以写为。假设一个 TodoItem 管理其删除操作,则必须将删除操作与更新的项目列表沿着结构向上传递给要调用的父级 TodoList。假设是由父级的 TodoList 管理项目的删除操作,它可能还是要将删除操作传递给子级的 TodoItem(也许执行一些清理动作)。


这里的底线是要将动作与给定的事件匹配,我们需要查看每个组件实现以了解事件及其处理的动作,以及它与组件树中依赖它的组件所使用的消息传递协议,然后对依赖组件重复相同的过程,直到下面没有依赖组件为止。只有这样,我们才能生成一个事件触发动作的完整列表。此外,组件通常是给定框架专属的,其选项取决于这个框架中可用的内容。


但是,我们选择的的框架是与规范分离的实现细节。实现应用程序和组件间消息传递的组件树,其特定形态(shape)在很大程度上也与规范紧密关联。于是考虑这样的问题:当用户遵循某个用户故事时,比如说当应用程序收到给定的事件序列[X,Y,…]时会发生什么情况?回答这类问题需要驯服来自于框架的特性、组件、状态管理和通信机制的次生复杂性


但是如果不回答这个问题,我们就不能确定实现是否符合规范,而符合规范就是软件的存在价值。随着用户故事的数量和大小继续增长,这种信心只会愈加脆弱。


而函数式 UI 技术试图从事件/动作对应关系中导出函数等式,从而直接反映用户界面的规范。由于等式是直接从规范中得出的,因此我们可以让实现尽可能接近规范。一般来说,这会减少实现错误的生存空间,并且会在开发的早期阶段就发现规范错误。由于函数式 UI 依赖于纯函数,因此可以轻松、可靠和快速地对用户故事进行单元测试。在某些情况下(状态机建模),甚至可以高度自动化地生成实现和测试。因为函数式 UI 只是标准的函数式编程,所以它不依赖于任何框架魔术。函数式 UI 可以很好地对接任何 UI 框架,需要的话也可以不使用任何框架。


本文将介绍函数式 UI 的意义,及其背后的基本函数等式,还会展示这种技术的具体用法示例,以及如何测试以这种风格编写的应用程序。与此同时,本文将努力揭示在 Web 应用程序开发中使用函数式 UI 方法的优缺点。

但什么是函数式 UI 呢?

任何用户界面应用程序都会隐式或显式地实现以下内容:


  1. 一个接口,应用程序通过它来接收事件

  2. 事件和动作之间的一种关系(~),形如:event ~ action,其中


  • 〜称为响应关系

  • event 是通过用户界面接收并触发接口系统上一个 action 的事件。事件可以是

  • 用户发起的(如按钮点击)

  • 系统发起的,即由环境或外部世界生成的(如 API 响应)


  1. 一个与外部系统对接的接口,必须通过该接口执行用户预期的动作


因为大多数响应式系统都是有状态的,所以一般来说关系〜不是一个数学函数(也就是只将一个输出关联到一个输入)。切换按钮就是一个简单的有状态 UI 应用程序。按下按钮一次,应用程序将呈现一个切换后的按钮。再按一次,应用程序将呈现一个切换前的按钮。由于相同的用户事件会在对接的输出设备(屏幕)上执行不同的渲染动作,因此应用程序是有状态的,无法定义一个数学函数使 action = f(event)。


我们称函数****式 UI 为用户界面应用程序的一组实现技术,其重点在于以下内容:


  • 将事件表示与事件调度分离开来

  • 将动作表示与动作执行分离开来

  • 将应用程序执行的动作与应用程序接收到的事件关联在一起的显式纯函数(响应函数


因此,函数式 UI 隔离了应用程序的效果部分(调度事件,运行效果),并将它们与纯函数链接在一起。结果,函数式 UI 自然会产生分层的架构,其中每一层仅与相邻层交互。最简单的分层架构由三层组成,可以表示如下:



命令处理程序(command handler)模块负责执行通过每个接口系统定义的编程接口所接收的命令。接口系统(interfaced system)可以将针对之前 API 调用的响应作为事件,发送给命令处理程序。接口系统还可以通过一个调度程序(dispatcher)将事件发送给应用程序。DOM 通常就是这种情况,它是以渲染命令的结果来做更新的,并且包含事件处理程序,它们只会调度事件。


这样的概念框架建立起来后,我们来介绍实现函数式 UI 的基本等式。

响应式系统的基本等式

在大多数情况下,一个响应式系统的状态可以表述为这样的形式:(action, new state) = f(state,event),其中:


  • f 是一个纯函数,

  • state 包含由环境和响应式系统的规范带来的所有可变性,这样 f 就是纯粹的。


这里的 f 被称为响应函数。如果我们用自然整数按时间顺序来索引,以使索引 n 对应于发生的第 n 个事件,则以下条件成立:


  • (action_n, state_n+1) = f(state_n, event_n) ,其中:

  • n 是响应式系统处理的第 n 个事件,

  • state_n 是处理第 n 个事件时响应式系统的状态,

  • 因此,在事件的发生和用于计算(compute)系统响应的状态之间存在一个隐式的时间关系


基于这些观察结果而诞生的实现技术依赖于一个响应函数 f,该函数为每个事件显式计算响应式系统的新状态,以及要执行的动作。这方面知名的例子有:


  • Elm:其中 update :: Msg -> Model -> (Model, Cmd Msg) 函数严格对应响应函数 f,Msg 对应 events,Model 对应状、states,Cmd Msg 对应 actions。

  • Pux(PureScript):其中 foldp :: Event -> State -> EffModel State Event 函数是 Pux 框架中的等效公式。在 Pux 中,EffModel State Event 是包含新状态值和一组效果(动作)的一个记录,这些效果可能会生成新的事件供应用程序处理。

  • Seed(Rust):其更新函数 fn update(msg: Msg, model: &mut Model, _: &mut impl Orders)对应的是 Elm 更新函数(Cmd 变成了 Orders),同时利用了 Rust 带来的可变性。


下面我们来看一些具体示例。在纯函数式语言中,函数式 UI 是使用这类语言编程的自然结果。在其他语言(例如 JavaScript)中,开发人员需要努力遵循函数式 UI 的原则。下文提供了分别使用纯函数式语言 Elm 和香草 JavaScript 编写函数式 UI 的示例。

示例

Elm

下面展示一个简单的Elm应用程序的示例,其在单击一个按钮时显示随机的小猫动图:



-- 按一个按钮,发送一个GET请求来获取随机的小猫动图。-- 工作机制介绍: https://guide.elm-lang.org/effects/json.html
(some imports...)
-- MAINmain = Browser.element { init = init , update = update , view = view }
-- MODELtype Model = Failure | Loading | Success String
-- Initial stateinit : () -> (Model, Cmd Msg)init _ = (Loading, getRandomCatGif)
-- UPDATEtype Msg = MorePlease | GotGif (Result Http.Error String)
update : Msg -> Model -> (Model, Cmd Msg)update msg model = case msg of MorePlease -> (Loading, getRandomCatGif)
GotGif result -> case result of Ok url -> (Success url, Cmd.none)
Err _ -> (Failure, Cmd.none)
-- VIEWview : Model -> Html Msgview model = div [] [ h2 [] [ text "Random Cats" ] , viewGif model ]
viewGif : Model -> Html MsgviewGif model = case model of Failure -> div [] [ text "I could not load a random cat for some reason. " , button [ onClick MorePlease ] [ text "Try Again!" ] ]
Loading -> text "Loading..."
Success url -> div [] [ button [ onClick MorePlease, style "display" "block" ] [ text "More Please!" ] , img [ src url ] [] ]
-- HTTPgetRandomCatGif : Cmd MsggetRandomCatGif = Http.get { url = "https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=cat" , expect = Http.expectJson GotGif gifDecoder }
gifDecoder : Decoder StringgifDecoder = field "data" (field "image_url" string)
复制代码


从代码中可以推断出:


  • 该应用程序始于某个初始状态,并运行一个初始命令(init _ = (Loading, getRandomCatGif))

  • 该初始状态会显示一个由 view 函数生成的初始视图

  • 点击一个 view 按钮会将 MorePlease 消息发送到 Elm 的运行时([ button [ onClick MorePlease, … ])

  • 其中 update 函数 update msg model = case msg of MorePlease -> (Loading, getRandomCatGif)将确保有一个 MorePlease 消息来获取一张随机的小猫动图,同时将应用程序的状态(model)更新为 Loading(从而使用户界面显示一条加载消息)。

  • 如果获取成功,它将返回一个 URL(GotGif Ok url 消息),使用户界面显示相应的图像(img [ src url ])


除了 update 函数外,Elm 还定义了一个运行时,负责接收事件,将事件传递给更新函数,并执行所计算的(computed)命令。因此,开发人员只需要定义应用程序状态和更新函数的内容。有了一个单独的,中心化的 update 函数来计算针对事件的响应,我们就能轻松回答"当事件[X,Y,……]发生时会出现什么情况"这样的问题。

香草 JavaScript

在 JavaScript 世界中,Hyperapp这个框架采用的架构深受 Elm 的影响,只是细节略有不同。Hyperapp 非常轻巧(2KB),其中大多数代码(80%)专门用来处理它自己的虚拟 DOM 实现。但是,Hyperapp 不会公开一个纯粹的响应函数,而是像 Elm 一样使用一个 view 函数。与 Elm 不同,这里的 view 函数不仅将某个状态作为其第一个参数来接收,还将包含应用程序可执行的所有动作的对象作为第二个参数来接收。


因此 view 函数不是纯函数,而是Jessica Kerr所描述的隔离函数。这意味着该函数仅有的依赖项是它的参数。纯函数是隔离的,但是隔离函数不一定是纯函数,因为它们的参数可能是生成效果的函数,或受外部世界控制的变量。但是如有必要,我们仍然可以通过 mocking 隔离函数的参数来对它们进行单元测试。于是乎,Hyperapp 无法遵循函数式 UI 的原则,但仍然保留了函数式 UI 的某些长处。


想要了解如何使用 Hyperapp 构建相对复杂的应用程序,读者可以参考 Hyperapp 的一个名为Conduit的(Medium克隆版示例应用)实现。这个应用程序也有一个Elm实现,以及其他十几个框架中的实现版本。


但在使用 JavaScript 实现用户界面时,无需放弃任何函数式 UI 原则。在一个假想的实现中,应用程序外壳负责将事件源连接到更新函数,并用类似的方式将更新函数连接到执行所计算的动作的模块,从而复制各种事件循环。update 函数可以采用以下形式(举例),用单个{command, params}对象编码其返回值(在 Elm 中为 Cmd Msg 类型)。


这里我们考虑使用前面讨论过的,显示随机小猫动图的应用程序,做一个JavaScript的等效实现。更新函数如下:


// Update functionfunction update(event, model) {  // Event has shape `{[eventName]: eventData}`  const eventName = Object.keys(event)[0];  const eventData = event[eventName];
if (eventName === MORE_PLEASE) { return { model: LOADING, commands: [ { command: GET_RANDOM_CAT_GIF, params: void 0 }, { command: RENDER, params: void 0 } ] }; } else if (eventName === GOT_GIF) { if (eventData instanceof Error) { return { model: FAILURE, commands: [{ command: RENDER, params: void 0 }] }; } else { const url = eventData; return { model: SUCCESS, commands: [{ command: RENDER, params: url }] }; } }
// 一些预期外的event, 应该什么都不会做 return { model: model, commands: [] };
复制代码


这里有一个基本的事件发射器用来调度事件。尽管这里可以使用任何 UI 框架的渲染函数,但这个简单演示中的渲染函数是通过直接 DOM 克隆来实现的。因此,命令执行如下:


[MORE_PLEASE, GOT_GIF].forEach(event => {  eventEmitter.on(event, eventData => {    const { model: updatedModel, commands } = update(      { [event]: eventData },      model    );    model = updatedModel;
if (commands) { commands.filter(Boolean).forEach(({ command, params }) => { if (command === GET_RANDOM_CAT_GIF) { getRandomCatGif() .then(response => { if (!response.ok) { console.warn(`Network request error`, response.status); throw new Error(response); } else return response.json(); }) .then(x => { if (x instanceof Error) { eventEmitter.emit(GOT_GIF, x); } if (x && x.data && x.data.image_url) { eventEmitter.emit(GOT_GIF, x.data.image_url); } }) .catch(x => { eventEmitter.emit(GOT_GIF, x); }); } if (command === RENDER) { if (model === LOADING) { setDOM(initViewEl.cloneNode(true), appEl); } else if (model === FAILURE) { setDOM(failureViewEl.cloneNode(true), appEl); } else if (model === SUCCESS) { const url = params; setDOM(successViewEl(url).cloneNode(true), appEl); } } }); } });
复制代码


如上所述,自己来实现函数式 UI 是非常简单的。如果你想重用现有的解决方案,可以考虑rajferp项目这些很有用的库,它们严格遵循函数式 UI 原则。你不必担心它们会超出你的应用程序预算。整个 raj 库非常小(33 行代码),因此可以完整粘贴在这里:


exports.runtime = function (program) {  var update = program.update  var view = program.view  var done = program.done  var state  var isRunning = true
function dispatch (message) { if (isRunning) { change(update(message, state)) } }
function change (change) { state = change[0] var effect = change[1] if (effect) { effect(dispatch) } view(state, dispatch) }
change(program.init)
return function end () { if (isRunning) { isRunning = false if (done) { done(state) } } }}
复制代码


尽管类似 Elm 的实现从根本上讲很简单,但与基于组件的实现相比,用它通常可以更好地了解应用程序的行为。一般来说,基于组件的实现可以让你很快搞明白用户界面会长什么样,但你可能不得不费力地从组件的实现细节中分辨出界面的行为(发生事件 X 时出现的情况)。换句话说,基于组件的实现可通过组件重用来优化生产力,而函数****式 UI 实现可将用例与实现匹配,从而提升正确性

单元测试用户场景

响应式系统运行时会产生踪迹(trace),也就是运行期间发生的(events, actions)序列。为了让响应式系统的行为正确,应设置一组允许的踪迹。相对应的,测试响应式系统时要验证实际踪迹与许可踪迹的集合是否匹配。从我们的基本等式得出的另一个纯函数可用于此用途:


For all n: (action_n, state_n+1) = f(state_n, event_n)
复制代码


先前的等式意味着:


(action_0, state_1) = f(state_0, event_0)(action_1, state_2) = f(state_1, event_1)(action_2, state_3) = f(state_2, event_2)...(action_n, state_n+1) = f(state_n, event_n)
复制代码


如果我们将 h 定义为将事件序列映射到相应动作序列的函数:


h([event_0]) = [action_0]h([event_0, event_1]) = [action_0, action_1]h([event_0, event_1, event_2]) = [action_0, action_1, action_2]h([event_0, event_1, event_2, ..., event_n]) = [action_0, action_1, action_2, ..., action_n]
复制代码


那么 h 就是一个纯函数!这意味着 h 可以很容易地测试,只需向其提供输入并检查它是否产生了预期的输出即可。请注意,在 h 中不会再提及应用程序的状态。由此以来我们就有了以下结果:


  • 可以单独测试用户场景,也就是说可以对各个用户场景进行单元测试,因为各个用户场景都是具有各自期望动作的事件序列

  • 针对应用程序的指定行为进行测试,而不是针对实现细节(例如应用程序状态的形状,或者用来获取数据的 HTTP 或套接字)进行测试

  • 对用户场景进行单元测试使开发人员能够遵循测试金字塔原则,并在他们的一大堆单元测试中添加少量针对性的集成和端到端测试

  • 因此,开发人员无需执行运行时间过长或不稳定的测试,他们的工作效率就会更高(集成和端到端测试编写起来昂贵且难以维护)

  • 开发人员可以选择任何测试框架(或哪个都不用)


当用户场景测试可以快速编写和执行时,就可以在给定的时间内设想和测试更多的用户场景。由于用户场景是简单的序列,因此更容易自动生成此类序列。在使用状态机对用户界面行为建模的情况下,实际上我们可以自动生成数以千计的测试,这样比起来手工且痛苦地编写测试,我们就可以覆盖更多用户场景和边缘案例。


最终的成果是我们能较早发现设计和实现错误,从而带来更快的迭代和更高的软件质量。毫无疑问,这是函数式 UI 技术的主要卖点,也是在安全性优先的软件开发项目中使用它们的关键因素所在。

结论

用户界面都是响应式系统,因此可以使用一个纯响应函数,将用户界面接受的事件映射到接口系统上的动作来定义用户界面。利用函数式编程的实现技术可以让实现更接近规范,更易推理和测试。函数式 UI 可以让开发人员摆脱不兼容的 UI 和测试框架带来的麻烦,并将重点转移到实现(how)上的规范(what)上。也许有人怀疑没有 UI 框架就没法开发严肃的应用程序,但我们要知道GitHub网站就不依赖任何UI框架


使用函数式 UI(它强调隔离的,单关注点的动作)和 UI 组件时,大多数时候我们只关注视图——一些框架将此类组件称为组件。此外,应用程序外壳程序会调用 UI 框架,而不是 UI 框架调用用户提供的框架感知函数。简而言之,UI 框架仍然可以使用,但它们现在只充当简单的库而已。


另一面来说,使用函数式 UI 时很难重用非纯组件,从而降低了框架组件生态系统的价值。此外,函数式 UI 需要前端开发人员在心态和方法上都做出转变,以前大家相比应用程序行为要更重视渲染(在屏幕上生成内容),并且更在乎生产效率(编写代码) 而非正确性(需要编写全面的测试)。


但是,Elm 在其七年的发展历程中已经验证了函数式 UI 方法的可行性,并证明只要有适当的工具,开发人员就可以快速学习并享受这种方法


原文链接


Functional UI (Framework-Free at Last)


2020-02-26 09:003784

评论

发布
暂无评论
发现更多内容

项目又延期了

escray

学习 极客时间 朱赟的技术管理课 6月日更

GitOps系列一:为什么协作技术对GitOps至关重要?

极狐GitLab

算法设计与分析——递归详解

若尘

算法 递归 6月日更

网络攻防学习笔记 Day32

穿过生命散发芬芳

网络攻防 6月日更

云原生中定时弹性伸缩之CronHPA实战

雪雷

6月日更

Java 中 HashSet 的 removeAll 性能分析

落日楼台H

Java 性能 HashSet removeAll 集合删除

【Flutter 专题】115 图解自定义 View 之 Canvas (四) drawParagraph

阿策小和尚

Flutter 小菜 0 基础学习 Flutter Android 小菜鸟 6月日更

在一架天车中,透视5G时代的钢铁智变

脑极体

面试官问我redis的string应用场景,我是这么回答的!

李阿柯

php lua redis 面试

基于MySQL Binlog 实现可配置的异构数据同步

王博

工业智能(汽车)联合创新实验室发布 力促汽车工业融通发展

浪潮云

让你编程能力秃飞猛进的好习惯

程序员鱼皮

Java c++ Python 大前端 自学编程

【译】JavaScript 代码整洁之道-变量篇

KooFE

JavaScript 大前端 变量 6月日更 整洁代码

认识微前端:一种用于前端 Web 开发的微服务

devpoint

大前端 SPA

【Vue2.x源码学习】第一篇-源码环境搭建

Brave

源码 vue2 6月日更

Grpc-go源码刨析

王博

Rust从0到1-泛型-trait

rust 泛型 Trait generic

一篇文章带你看懂计算机系统监控与可观测性发展史(干货)

观测云

云计算 可观测性

如何理解梯度下降算法Gradient Descent algorithm John 易筋 ARTS 打卡 Week 49

John(易筋)

ARTS 打卡计划

六一限定,致每一个追光者

脑极体

Dubbo 服务在线测试

青年IT男

dubbo

[万字总结] 一文吃透 Webpack 核心原理

范文杰

大前端 webpack 6月日更

40 图|硬核解析用 Mac M1 玩转 SpringCloud

悟空聊架构

Spring Cloud Mac SpringCloud Alibaba m1 6月日更

Spring Cloud Alibaba 实战

Damon

微服务 SpringCloud Alibaba 6月日更

bzz矿机分币系统开发,BZZ矿机节点APP搭建

springboot+mongo多数据源简单配置

Mars

mongo 多数据源配置

架构抉择之分合矩阵

凌晞

架构

树莓派上的自动化---自动发送IP地址到邮箱

IT蜗壳-Tango

树莓派 IT蜗壳教学 6月日更

算法训练营 - 学习笔记 - 第八周

心在飞

记录下PVE 装openwrt 后 pve 本身不能上网问题

三爻

react源码解析3.react源码架构

全栈潇晨

React React Hooks react源码

函数式UI:Web开发终于摆脱了框架的束缚_编程语言_Bruno Couriol_InfoQ精选文章