本文要点
顾名思义,用户界面允许用户与其他系统交互,其理念是:相比直接与其他系统互动,这种交互界面会提供一些用户期望的好处。用户通过某种输入方式(例如按键或声音输入)表达意图,然后用户界面通过在接口系统上预定义的动作来做出响应。用户界面基本上是天然的响应式系统。用户界面的任何规范技术都必须详细说明用户界面输入和接口系统上的动作之间的对应关系,也就是应用程序的行为规范。这样一来,就可以根据用户发起或应用程序接受的一系列事件,以及系统对应的预期反应来定义一个用户故事。
许多用来实现用户界面的框架(Angular2、Vue和React等)都使用回调过程或事件处理程序,后者会作为事件的结果而直接执行相应的动作。决定要执行哪个动作(例如输入验证、本地状态更新、错误处理或数据获取等),通常意味着要访问和更新某些状态,而这些状态并不总是在作用域内。因此框架会包含一些状态管理或通信能力,以处理所需的相关状态的传递,并在允许和要求时更新状态。
基于组件的用户界面实现往往包含一些状态,而动作以不明显的方式沿着组件树散布开来。例如,一个待办事项列表应用程序可以写为。假设一个TodoItem管理其删除操作,则必须将删除操作与更新的项目列表沿着结构向上传递给要调用的父级TodoList。假设是由父级的TodoList管理项目的删除操作,它可能还是要将删除操作传递给子级的TodoItem(也许执行一些清理动作)。
这里的底线是要将动作与给定的事件匹配,我们需要查看每个组件实现以了解事件及其处理的动作,以及它与组件树中依赖它的组件所使用的消息传递协议,然后对依赖组件重复相同的过程,直到下面没有依赖组件为止。只有这样,我们才能生成一个事件触发动作的完整列表。此外,组件通常是给定框架专属的,其选项取决于这个框架中可用的内容。
但是,我们选择的的框架是与规范分离的实现细节。实现应用程序和组件间消息传递的组件树,其特定形态(shape)在很大程度上也与规范紧密关联。于是考虑这样的问题:当用户遵循某个用户故事时,比如说当应用程序收到给定的事件序列[X,Y,…]时会发生什么情况?回答这类问题需要驯服来自于框架的特性、组件、状态管理和通信机制的次生复杂性。
但是如果不回答这个问题,我们就不能确定实现是否符合规范,而符合规范就是软件的存在价值。随着用户故事的数量和大小继续增长,这种信心只会愈加脆弱。
而函数式UI技术试图从事件/动作对应关系中导出函数等式,从而直接反映用户界面的规范。由于等式是直接从规范中得出的,因此我们可以让实现尽可能接近规范。一般来说,这会减少实现错误的生存空间,并且会在开发的早期阶段就发现规范错误。由于函数式UI依赖于纯函数,因此可以轻松、可靠和快速地对用户故事进行单元测试。在某些情况下(状态机建模),甚至可以高度自动化地生成实现和测试。因为函数式UI只是标准的函数式编程,所以它不依赖于任何框架魔术。函数式UI可以很好地对接任何UI框架,需要的话也可以不使用任何框架。
本文将介绍函数式UI的意义,及其背后的基本函数等式,还会展示这种技术的具体用法示例,以及如何测试以这种风格编写的应用程序。与此同时,本文将努力揭示在Web应用程序开发中使用函数式UI方法的优缺点。
任何用户界面应用程序都会隐式或显式地实现以下内容:
因为大多数响应式系统都是有状态的,所以一般来说关系〜不是一个数学函数(也就是只将一个输出关联到一个输入)。切换按钮就是一个简单的有状态UI应用程序。按下按钮一次,应用程序将呈现一个切换后的按钮。再按一次,应用程序将呈现一个切换前的按钮。由于相同的用户事件会在对接的输出设备(屏幕)上执行不同的渲染动作,因此应用程序是有状态的,无法定义一个数学函数使action = f(event)。
我们称函数****式UI为用户界面应用程序的一组实现技术,其重点在于以下内容:
因此,函数式UI隔离了应用程序的效果部分(调度事件,运行效果),并将它们与纯函数链接在一起。结果,函数式UI自然会产生分层的架构,其中每一层仅与相邻层交互。最简单的分层架构由三层组成,可以表示如下:
命令处理程序(command handler)模块负责执行通过每个接口系统定义的编程接口所接收的命令。接口系统(interfaced system)可以将针对之前API调用的响应作为事件,发送给命令处理程序。接口系统还可以通过一个调度程序(dispatcher)将事件发送给应用程序。DOM通常就是这种情况,它是以渲染命令的结果来做更新的,并且包含事件处理程序,它们只会调度事件。
这样的概念框架建立起来后,我们来介绍实现函数式UI的基本等式。
在大多数情况下,一个响应式系统的状态可以表述为这样的形式:(action, new state) = f(state,event),其中:
这里的f被称为响应函数。如果我们用自然整数按时间顺序来索引,以使索引n对应于发生的第n个事件,则以下条件成立:
基于这些观察结果而诞生的实现技术依赖于一个响应函数f,该函数为每个事件显式计算响应式系统的新状态,以及要执行的动作。这方面知名的例子有:
下面我们来看一些具体示例。在纯函数式语言中,函数式UI是使用这类语言编程的自然结果。在其他语言(例如JavaScript)中,开发人员需要努力遵循函数式UI的原则。下文提供了分别使用纯函数式语言Elm和香草JavaScript编写函数式UI的示例。
下面展示一个简单的Elm应用程序的示例,其在单击一个按钮时显示随机的小猫动图:
-- 按一个按钮,发送一个GET请求来获取随机的小猫动图。
-- 工作机制介绍: https://guide.elm-lang.org/effects/json.html
(some imports...)
-- MAIN
main =
Browser.element
{ init = init
, update = update
, view = view
}
-- MODEL
type Model
= Failure
| Loading
| Success String
-- Initial state
init : () -> (Model, Cmd Msg)
init _ =
(Loading, getRandomCatGif)
-- UPDATE
type 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)
-- VIEW
view : Model -> Html Msg
view model =
div []
[ h2 [] [ text "Random Cats" ]
, viewGif model
]
viewGif : Model -> Html Msg
viewGif 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 ] []
]
-- HTTP
getRandomCatGif : Cmd Msg
getRandomCatGif =
Http.get
{ url = "https://api.giphy.com/v1/gifs/random?api_key=dc6zaTOxFJmzC&tag=cat"
, expect = Http.expectJson GotGif gifDecoder
}
gifDecoder : Decoder String
gifDecoder =
field "data" (field "image_url" string)
从代码中可以推断出:
除了update函数外,Elm还定义了一个运行时,负责接收事件,将事件传递给更新函数,并执行所计算的(computed)命令。因此,开发人员只需要定义应用程序状态和更新函数的内容。有了一个单独的,中心化的update函数来计算针对事件的响应,我们就能轻松回答"当事件[X,Y,……]发生时会出现什么情况"这样的问题。
在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 function
function 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是非常简单的。如果你想重用现有的解决方案,可以考虑raj或ferp项目这些很有用的库,它们严格遵循函数式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中不会再提及应用程序的状态。由此以来我们就有了以下结果:
当用户场景测试可以快速编写和执行时,就可以在给定的时间内设想和测试更多的用户场景。由于用户场景是简单的序列,因此更容易自动生成此类序列。在使用状态机对用户界面行为建模的情况下,实际上我们可以自动生成数以千计的测试,这样比起来手工且痛苦地编写测试,我们就可以覆盖更多用户场景和边缘案例。
最终的成果是我们能较早发现设计和实现错误,从而带来更快的迭代和更高的软件质量。毫无疑问,这是函数式UI技术的主要卖点,也是在安全性优先的软件开发项目中使用它们的关键因素所在。
用户界面都是响应式系统,因此可以使用一个纯响应函数,将用户界面接受的事件映射到接口系统上的动作来定义用户界面。利用函数式编程的实现技术可以让实现更接近规范,更易推理和测试。函数式UI可以让开发人员摆脱不兼容的UI和测试框架带来的麻烦,并将重点转移到实现(how)上的规范(what)上。也许有人怀疑没有UI框架就没法开发严肃的应用程序,但我们要知道GitHub网站就不依赖任何UI框架。
使用函数式UI(它强调隔离的,单关注点的动作)和UI组件时,大多数时候我们只关注视图——一些框架将此类组件称为纯组件。此外,应用程序外壳程序会调用UI框架,而不是UI框架调用用户提供的框架感知函数。简而言之,UI框架仍然可以使用,但它们现在只充当简单的库而已。
另一面来说,使用函数式UI时很难重用非纯组件,从而降低了框架组件生态系统的价值。此外,函数式UI需要前端开发人员在心态和方法上都做出转变,以前大家相比应用程序行为要更重视渲染(在屏幕上生成内容),并且更在乎生产效率(编写代码) 而非正确性(需要编写全面的测试)。
但是,Elm在其七年的发展历程中已经验证了函数式UI方法的可行性,并证明只要有适当的工具,开发人员就可以快速学习并享受这种方法。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货