Skip to content

思考MVVM的边界

2017年7月11日

前提

MVVM 的概念由来已久,一开始,伴随着非常多的争议,MVC 还是 MVP 还是 MVVM ?MVVM 中什么是 VM ?不过随着时间的推移,大家也不再纠结到底是 MV 什么鬼了,于是也有人叫它们为 MV* 。为行文方便,下文的 MVVM 并不特指 Angular.js 之类双向绑定的框架,更多是 MV* 的意义。

不管它有什么争议,核心的数据绑定机制上,大家是没有太多争议的。Angular.js 带来的数据双向绑定至今让人印象深刻。而 React 带来的单向数据流

UI=f(state)

在今天已经几乎成为主流,在其它框架中都能找到它的影子。在 Vue 中也能找到它的身影,尤其是在引入 Vuex 之后,几乎就是 Flux 的翻版,概念几乎完全一样。

今天要讨论的主题就是上面这个公式的边界,为避免 MVVM 名称带来的争议,下文直接使用“框架”一词。

数据与UI的映射

math function

我们在数学中都学过 y = f(x) ,表示对每一个确定的 x ,都有唯一的 y 值与它对应。例如 y = x + 1,不论 x 取什么值,都能找到确定的 y 与之对应。而反过来理解,y 值的不同正是因为 x 取值不同导致的。

UI = F(state)

这也是一个典型的函数。它表示每一种 state 都有唯一与之对应的 UI 表现。反过来,UI 表现的不同正是因为 state 的不同导致的。

正是因为这种映射的确定性,带来了极易理解的 UI 开发方法:我们只需要编写好映射关系,然后注意力就可以全部放到 state 上面了,因为 state 的任何变动都会体现到 UI 上,而 UI 是不会在 state 不变的情况下发生变更的。而因为同样的 state 对应同样的 UI ,我们甚至可以完全将用户当前的 UI 复制到另一台设备上,只要保证 state 是完全相同的即可。

初窥边界

如果世界真的这么简单,那也许就不会有前端工程师一职了。

回想一个真实的案例,如果我们要将一个字符串显示在界面上,那么可能是这么写:

html
<span>{{text}}</span>
<span>{{text}}</span>
javascript
this.setState({
  text:'hello world'
});
this.setState({
  text:'hello world'
});

此时 UI 完全由 state 决定。但是,如果这是一个输入框呢?

html
<input value="{{text}}" />
<input value="{{text}}" />

这样写吗?那输入框输入的时候会发生什么?熟悉 React 的朋友应该知道,在 React 中,有一种 input 叫作 Controlled Input ,它需要监听输入框的事件,当内容变更的时候,实时改变 state 的值,从而再次达到 state 和 UI 一致的目的。然而这种情况下与其说是 state 控制 UI ,倒不如说是 UI 在控制 state 了。

controlled input

如果说内容变更还可以勉强通过 Controlled Input 的方式来保持 state 到 UI 的映射,那光标的控制就只能说是无能为力了。

理论上来说,当我们输入的时候,光标也在同步变更,此时如果我们继续应用 state 到 UI 的映射,是有可能导致输入框被重新渲染的,而此时 input 中的光标是无法保证位置的。这样会导致非常奇怪的输入体验,或者说其实是没法用的。框架在处理输入框上下了非常多的功夫,其实是避免了 state 变更的时候重新渲染 input 的,最多只是改变它的属性(值)。

至此,我们其实已经看到了,所谓 state 控制 UI 也只是在我们的理想中存在,现实中有非常多的细节是无法通过 state 到 UI 的映射关系照顾到的。

再探边界

上面的例子,为什么在设计 state 的时候,不把光标也设计进去呢?这样不就可以进行精准的 state 到 UI 的控制了么?

我们在脑海中尝试一下:首先,需要在 state 中为每个输入框的值增加一个光标位置。接下来,需要在每一次值变更的时候计算光标位置,并通过光标 API 维护界面上光标的位置。然后,需要监听 focus / blur 事件,重建、移除光标。需要监听点击事件重算光标位置,需要监听键盘事件,使光标位置发生跳跃。除此之外,还需要处理选区,此时是否还需要在 state 中添加一个选区呢?

我们只是想控制一下光标而已……

而如果我们不控制光标(也就是主流框架的现状),你会发现事情要更容易得多,我们上述种种光标的行为浏览器都有对应的处理和反应。也就是说,浏览器把这些行为都已经封装到了 input 组件中,而我们在大部分情况下,并不需要处理这些。这个封装行为,实际上就划出了一条边界:框架应该只管理封装组件对外暴露的部分,未暴露的部分不应该介入。

再举一例,当我们引入一个文本编辑器组件(例如 AceEditor )时,这个编辑器除了内容之外,还会有非常多的行为,例如是否显示 查找/替换 的界面,是否显示行号、参考线,使用不同的语法进行高亮等等。以高亮这一行为来说,本质上它是由一堆带样式的<span>元素堆起来的,例如var a=123这样一句简单的代码,它实际的结构可能是这样的:

html
<span class="keyword">var </span><span class="variable">a</span><span class="operate">=</span> <span class="const">123</span>
<span class="keyword">var </span><span class="variable">a</span><span class="operate">=</span> <span class="const">123</span>

此时我们需要将这个高亮的结构通过 state 来映射吗?还是在 state 中直接保存 var a=123 这样一个字符串就好了?答案不言自明。

highlight

至此,我们已经能非常明白地对 MVVM 的边界有一个认知。这条边界,就是组件的封装,面对一个封装的组件,我们不应该跨过封装的边界。

但,问题又来了,如果组件的行为并不能通过 MVVM 的 state 来决定,那么,由谁决定?