函数式 UI:Web开发终于摆脱了框架的束缚
为什么要使用函数式 UI?
顾名思义,用户界面允许用户与其他系统 交互 ,其理念是:相比直接与其他系统互动,这种交互界面会提供一些用户期望的好处。用户通过某种输入方式(例如按键或声音输入)表达意图,然后用户界面通过在接口系统上预定义的动作来做出响应。 用户界面基本上是天然的响应式系统 。用户界面的任何规范技术都必须详细说明 用户界面输入和接口系统上的动作之间的对应关系 ,也就是应用程序的 行为 规范。这样一来,就可以根据用户发起或应用程序接受的一系列事件,以及系统对应的预期反应来定义一个用户故事。
许多用来实现用户界面的框架(Angular2、vue 和 react 等)都使用回调 过程 或事件处理程序,后者会作为事件的结果而 直接执行 相应的动作。决定要执行哪个动作(例如输入验证、本地状态更新、错误处理或数据获取等),通常意味着要访问和更新某些状态,而这些状态并不总是在作用域内。因此框架会包含一些状态管理或通信能力,以处理所需的相关状态的传递,并在允许和要求时更新状态。
基于组件的用户界面实现往往包含一些状态,而动作以不明显的方式沿着组件树散布开来。例如,一个 待办事项列表应用程序 可以写为 。假设一个 TodoItem 管理其删除操作,则必须将删除操作与更新的项目列表沿着结构向上传递给要调用的父级 TodoList。假设是由父级的 TodoList 管理项目的删除操作,它可能还是要将删除操作传递给子级的 TodoItem(也许执行一些清理动作)。
这里的底线是要将动作与给定的事件匹配,我们需要查看每个组件实现以了解事件及其处理的动作,以及它与组件树中依赖它的组件所使用的消息传递协议,然后对依赖组件重复相同的过程,直到下面没有依赖组件为止。只有这样,我们才能生成一个事件触发动作的完整列表。此外,组件通常是给定框架专属的,其选项取决于这个框架中可用的内容。
但是,我们选择的的框架是与规范分离的实现细节。实现应用程序和组件间消息传递的组件树,其特定形态(shape)在很大程度上也与规范紧密关联。于是考虑这样的问题:当用户遵循某个用户故事时,比如说当应用程序收到给定的事件序列 [X,Y,…] 时会发生什么情况?回答这类问题需要驯服来自于框架的特性、组件、状态管理和通信机制的 次生复杂性 。
但是如果不回答这个问题,我们就不能确定实现是否符合规范,而符合规范就是软件的存在价值。随着用户故事的数量和大小继续增长,这种信心只会愈加脆弱。
而函数式 UI 技术试图从事件 / 动作对应关系中导出函数等式,从而直接反映用户界面的规范。由于等式是直接从规范中得出的,因此我们可以让实现尽可能接近规范。一般来说,这会减少实现错误的生存空间,并且会在开发的早期阶段就发现规范错误。由于函数式 UI 依赖于纯函数,因此可以轻松、可靠和快速地对用户故事进行单元测试。在某些情况下(状态机建模),甚至可以高度自动化地生成实现和测试。因为函数式 UI 只是标准的函数式编程,所以它不依赖于任何框架魔术。函数式 UI 可以很好地对接任何 UI 框架,需要的话也可以不使用任何框架。
本文将介绍函数式 UI 的意义,及其背后的基本函数等式,还会展示这种技术的具体用法示例,以及如何测试以这种风格编写的应用程序。与此同时,本文将努力揭示在 Web 应用程序开发中使用函数式 UI 方法的优缺点。
但什么是函数式 UI 呢?
任何用户界面应用程序都会隐式或显式地实现以下内容:
- 一个接口,应用程序通过它来接收事件
- 事件和动作之间的一种关系(~),形如:event ~ action,其中
- 〜称为响应关系
- event 是通过用户界面接收并触发接口系统上一个 action 的事件。事件可以是
- 用户发起的(如按钮点击)
- 系统发起的,即由环境或外部世界生成的(如 API 响应)
- 一个与外部系统对接的接口,必须通过该接口执行用户预期的动作
因为大多数响应式系统都是有状态的,所以一般来说关系〜不是一个 数学函数 (也就是 只将一个 输出关联到一个输入)。切换按钮就是一个简单的有状态 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...)
-- 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)
从代码中可以推断出:
- 该应用程序始于某个初始状态,并运行一个初始命令(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 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 实现可将用例与实现匹配,从而提升正确性 。
资源搜索网站大全 https://www.renrenfan.com.cn 广州VI设计公司https://www.houdianzi.com
单元测试用户场景
响应式系统运行时会产生踪迹(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 技术的主要卖点,也是在安全性优先的软件开发项目中使用它们的关键因素所在。