思考MVVM的边界
前提
MVVM 的概念由来已久,一开始,伴随着非常多的争议,MVC 还是 MVP 还是 MVVM ?MVVM 中什么是 VM ?不过随着时间的推移,大家也不再纠结到底是 MV 什么鬼了,于是也有人叫它们为 MV 。为行文方便,下文的 MVVM 并不特指 Angular.js 之类双向绑定的框架,更多是 MV 的意义。
不管它有什么争议,核心的数据绑定机制上,大家是没有太多争议的。Angular.js 带来的数据双向绑定至今让人印象深刻。而 React 带来的单向数据流
在今天已经几乎成为主流,在其它框架中都能找到它的影子。在 Vue 中也能找到它的身影,尤其是在引入 Vuex 之后,几乎就是 Flux 的翻版,概念几乎完全一样。
今天要讨论的主题就是上面这个公式的边界,为避免 MVVM 名称带来的争议,下文直接使用“框架”一词。
数据与UI的映射
我们在数学中都学过 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 是完全相同的即可。
初窥边界
如果世界真的这么简单,那也许就不会有前端工程师一职了。
回想一个真实的案例,如果我们要将一个字符串显示在界面上,那么可能是这么写:
1 | <span>{{text}}</span> |
1 | this.setState({ |
此时 UI 完全由 state 决定。但是,如果这是一个输入框呢?
1 | <input value="{{text}}" /> |
这样写吗?那输入框输入的时候会发生什么?熟悉 React 的朋友应该知道,在 React 中,有一种 input 叫作 Controlled Input ,它需要监听输入框的事件,当内容变更的时候,实时改变 state 的值,从而再次达到 state 和 UI 一致的目的。然而这种情况下与其说是 state 控制 UI ,倒不如说是 UI 在控制 state 了。
如果说内容变更还可以勉强通过 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
这样一句简单的代码,它实际的结构可能是这样的:
1 | <span class="keyword">var </span><span class="variable">a</span><span class="operate">=</span> <span class="const">123</span> |
此时我们需要将这个高亮的结构通过 state 来映射吗?还是在 state 中直接保存 var a=123 这样一个字符串就好了?答案不言自明。
至此,我们已经能非常明白地对 MVVM 的边界有一个认知。这条边界,就是组件的封装,面对一个封装的组件,我们不应该跨过封装的边界。
但,问题又来了,如果组件的行为并不能通过 MVVM 的 state 来决定,那么,由谁决定?