🔂 数据驱动模式下状态和视图层逻辑
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
之前说过数据层相关的内容:
这些文章涉及了很多数据层、性能、副作用相关的内容。今天我想换个角度,从无关框架和语言的角度,深入探讨数据驱动模式开发的核心逻辑——不是讨论某个具体的库,而是思考状态应该如何设计、数据如何流转、视图如何响应。
如果经常做大前端业务,尤其是数据驱动模式 + 声明描述式的 UI 开发比如 React、Vue、Flutter、SwiftUI ,很明显的会感觉到数据驱动的开发模式,视图层和数据层分离解耦的开发思路,用状态来控制视图的展示。
数据驱动开发的模式,准确点说是视图是渲染函数根据数据的输入计算输出
View = render(Data),数据状态的变更响应到视图,视图的事件影响数据状态变动。更广义上来讲,状态变更能够被订阅(观测、响应、代理、连调之类的逻辑),其实就算是一种数据驱动开发了。这种开发模式的好处是在于业务重点会放在这些数据的维护上,数据是核心,也本应该就是核心,周边是修改和维护的方法。剩下的才是上层的视图和交互。
数据驱动模式:视图层是由数据驱动的数据层:数据的存储、修改、通知、订阅等
从整个应用的角度,状态层/数据层的抽象设计是一个很核心的东西。
JQuery 时代,大部分的界面都是在处理什么操作修改视图的哪个部分,很多时候都在做双向同步的胶水代码,比如在视图上绑定监听函数,用户操作触发函数,函数根据操作更新视图。其中对视图的依赖很重,可以说逻辑几乎都是依附在视图层的。
vue 因为模板优先,所以计算部分偏爱放到生命周期、computed、watch 这些地方,视图的模板里面放的是轻量的判断循环等。从这个角度来说视图和数据模型的对应更加纯粹,render 这个概念被接管淡化,开发者维护数据模型和视图模板,状态的变更和响应 vue 来做。
ps: vue 的模板说到底其实也是计算的本质,不过这个计算用模板语法限制了灵活性。比如为了控制一个元素的显示隐藏也要设置一个状态来控制才不会弄脏模板,而 react 直接把判断的计算放到 render 里面也不会太影响可读性。
而 react 是计算优先,render 函数就是个计算过程,即使用 JSX 这类语法糖也改变不了计算的本质,从这个角度来说整个组件都是在为计算服务,什么时候计算的生命周期,和计算用到的状态。render 直接交给开发者来做能够有足够的灵活性,这也是很多人喜欢的地方,你可以用优秀的数据模型来搭建视图,也可以用逻辑来弥补数据模型的破落。
此外因为 render 的计算如果太重会影响到性能的表现和事件的响应,所以很多篇章和思考都放在减少 render 的调用和里面的计算。而 render 被 props 和 state 控制,所以状态的设计也是很重要的地方。这个和 vue 的心智倒也是殊途同归。
从上面的叙述中,很明显能够感觉出一个逻辑: “你需要精心设计好数据模型和组件状态才能得到一个运转良好的视图表现。” 无论是什么框架或者语言,这种架构优秀的应用都是减少状态变更牵扯的计算和渲染。状态与试图视图层的更新机制、浏览器进程管理等
数据驱动的核心就一句话:Single Source Of Truth。应用的状态只有一个权威来源,视图是它的投影,事件是它的输入。
听起来很简单,但实际做起来状态很容易就散了。组件自身的 state、context 里传的值、URL 上的参数、localStorage 里缓存的数据、甚至 DOM 本身暴露出来的滚动位置——这些都是状态,如果不刻意去收拢,它们就会各自为政,变成一堆难以追踪的隐式依赖。
所以在设计数据层的时候,首先要回答一个问题:哪些状态是源数据,哪些是派生数据?
源数据(source data)是业务上独立存在的,比如用户信息、列表数据、当前的筛选条件。派生数据(deriveddata)是从源数据计算出来的,比如过滤后的列表、格式化后的金额、某个条件是否满足。
这两者的区分很重要。派生数据不应该被单独存储,它应该始终由源数据实时计算得出。如果你把派生数据也存一份,就等于维护了两个 source of truth,它们之间的同步就是一个无底洞——这和 jQuery 时代双向同步的胶水代码本质上是一回事,只是换了个形式。
颗粒度怎么切是一个很实际的问题。太粗了,一个状态对象里塞了几十个字段,任何一个字段变了整棵组件树都要重新走一遍 diff;太细了,状态数量爆炸,订阅关系变得复杂,调试的时候要在十几个地方跳来跳去。
比较务实的做法是按业务域来划分。一个表单的状态、一个列表的状态、一个用户会话的状态——各自是独立的model,各自管理自己的源数据和修改方法。不同 model 之间通过事件或引用来通信,而不是互相直接操作对方的状态。
在 NOVUS 的设计里我就是按这个思路来的,一个 Model 就是一个业务域的 state + actions,订阅者只关心自己依赖的 Model 变了没有,不会因为不相关的状态变更被牵扯进去。
不可变数据在数据驱动开发里几乎是一个标配。React 自不必说,Vue 3 的 reactive 虽然底层是 Proxy 做的响应式,但如果你看过它的源码,对数组和对象的变更检测其实也是在模拟不可变的语义——只不过框架替你做了这件事。
不可变的核心价值不在于"防止意外修改"这种安全层面的考量(虽然也有),而是在于比较成本。
a === b在引用级别是 O(1) 的,如果数据是不可变的,那引用没变就意味着数据没变,整个 diff 过程可以快速跳过。但完全不可变意味着每次修改都要复制,大对象复制起来代价很高。所以实际用的是结构共享(structural sharing)——Immutable.js 的 Record、immer 的 Proxy 自动生成 draft、甚至 React 18 里的一些内部优化,本质上都是这个思路:修改时只复制变更路径上的节点,其他分支共享引用。
数据层还要处理一个问题:副作用放在哪里。
网络请求、定时器、DOM 操作——这些不属于纯数据变更,它们是副作用。在 Flux 体系里副作用通常放在 action 层(redux-thunk、redux-saga、dva 的 effects),在 Vue 里可以放在 watch 或者 action 里。
关键原则是:数据层的核心路径应该是同步的、可预测的。副作用可以有,但它们不应该阻塞状态更新的核心
链路。一个状态变更触发了重新渲染,重新渲染里不应该包含异步逻辑;一个异步操作完成了,它更新状态,然后
状态变更再触发渲染。这条链路始终是单向的,异步只是触发状态变更的手段,而不是状态变更的一部分。
在 UI 开发领域,声明式(描述性)的代码几乎总是优于命令式的。原因很简单:UI 是一个"根据输入数据计算输出画面"的过程,这天然就是一个纯函数的形态,
View = render(Data)。命令式的代码是在描述"怎么做":先找到这个 DOM 节点,然后设置它的文本内容,然后给它加个 class,然后...。一旦 UI 复杂起来,这些步骤之间的顺序依赖和状态纠缠会变得非常痛苦。而且命令式代码和最终的视觉效果之间隔了好几层中间过程,你很难一眼看出这段代码会渲染出什么样子。
声明式的代码是在描述"是什么":当 data 是 A 的时候显示 X,是 B 的时候显示 Y。框架负责把"是什么"翻译成具体的 DOM 操作。开发者不需要关心中间的 diff 过程和 DOM 操作,注意力可以集中在数据到视图的映射关系上。
React 的 JSX 和 Vue 的 template 都是声明式的,只是形式不同。JSX 是在 JavaScript 里写 UI,template 是在 UI 里嵌入轻量的表达式。前者灵活度更高,后者对设计师友好度更高。但本质上它们都在做同一件事:描述数据到视图的映射。
声明式框架的基石是 Reconciliation(协调算法)。每次状态变更后,框架需要算出"当前视图"和"新视图"之间的最小差异,然后只更新差异部分。这个过程如果做得好,就能避免大量的无效 DOM 操作。
React 的做法是虚拟 DOM + Fiber 架构。生成新的虚拟 DOM 树,和旧的树做 diff,找出需要更新的节点,然后批量操作真实 DOM。Fiber 把 diff 过程拆成小任务,可以暂停和恢复,配合 Scheduler 实现时间切片——每帧给 ms 级别的预算,超了就让出主线程给浏览器做渲染和响应事件,下一帧继续。
Vue 的做法略有不同。Vue 2 用的是虚拟 DOM,但配合响应式系统做了更细粒度的依赖收集——它知道哪些组件依赖了哪些数据,数据变了只需要重新渲染相关的组件,不需要整棵树走一遍 diff。Vue 3 更进一步,编译阶段就能静态分析出哪些节点是静态的(不会变),运行时直接跳过,还能做基于 Proxy 的精确更新。
两者路径不同,目标一致:减少不必要的计算和 DOM 操作。React 侧重在运行时的调度优化,Vue 侧重在编译期和响应式的精确追踪。
状态到视图的更新方式,大致可以分成两派:
functional update(函数式更新):React 的
setState 是典型代表。你告诉框架"状态应该变成什么",框架负责触发重新计算和渲染。setState(prev => prev + 1) 这种写法本身就是一个函数,描述的是状态转换,而不是直接赋值。data-binding / reactivity(数据绑定 / 响应式):Vue 是典型代表。你修改数据,框架自动检测到变更并更新视图。开发者不需要显式地"告诉"框架去更新,因为框架通过 Proxy(Vue 3)或者
Object.defineProperty(Vue 2)拦截了数据的读写,在写操作发生时自动触发视图更新。这两者的区别在于依赖关系的建立方式不同。
React 的依赖关系是隐式的——render 函数里读了哪些 state/props,React 并不知道(除非你用
useMemo 的deps 数组显式声明)。所以 React 需要每次 state 变了就重新执行 render,然后在 diff 阶段判断实际输出有没有变化。Vue 的依赖关系是显式的——响应式系统在 render 过程中会追踪哪些响应式数据被访问了,建立了精确的依赖图。数据变了,只有依赖了这个数据的组件会重新渲染,不需要整棵树走 diff。
从理论上说,Vue 的方式在精细度上更有优势,因为依赖收集是静态的、确定性的。React 的方式需要更粗粒度的重新计算,然后在 diff 阶段来过滤掉无效更新。这也是为什么 React 社区里有大量的优化技巧(
React.memo、useMemo、useCallback、PureComponent)——本质上都是在手动弥补依赖追踪的缺失。ps: React Hooks 的 deps 数组某种程度上就是在手动声明依赖,和 Vue 的自动收集依赖是同一个问题的两种解法。只不过一个靠开发者自觉,一个靠框架拦截。
把前面说的串起来,一次完整的状态驱动视图更新的链路大概是这样的:
用户操作 → 事件处理 → 修改状态 → 框架检测到变更 → 重新计算视图 → diff → 更新 DOM
这里面有几个环节是可以被优化的:
- 修改状态到检测变更:React 靠调用栈(setState 被调用就算变更),Vue 靠 Proxy 拦截。
- 重新计算视图:React 是重走 render(整个组件树或 memo 过滤后的子树),Vue 是只走依赖了这个数据的组件。这个环节是性能开销的大头。
- diff:React 的 Fiber diff 是可中断的,Vue 3 的编译期优化可以跳过静态节点。两者的 diff 策略都是同层比较 + key 匹配,复杂度 O(n)。
- 更新 DOM:最终落实到 DOM 操作。React 会把多次 DOM 操作批量执行,Vue 也有类似的机制。这个环节的优化空间相对较小,因为 DOM API 本身的开销是固定的。
前面的讨论都集中在框架层面,但实际上还有一个更底层的玩家:浏览器本身。
现代浏览器的架构大致是:浏览器进程(主进程)→ GPU 进程 → 渲染进程(每个 tab 一个)→ 网络进程。渲染进程内部又有主线程、合成线程、光栅线程等。
JavaScript 运行在渲染进程的主线程上,和样式计算、布局(Layout)、绘制(Paint)是同一个线程。这意味着如果 JavaScript 执行时间太长,浏览器来不及在 16ms 内完成样式计算、布局、绘制和合成,帧就会丢,用户就会感觉到卡。
React Fiber 的设计初衷就是为了解决这个问题。在 Fiber 之前,React 的 diff 是同步递归的,一旦开始就无法中断,大组件树的 diff 可能占据主线程几十毫秒甚至上百毫秒。Fiber 把递归改成了循环 + 链表,每个节点是一个 Fiber 对象,通过 child、sibling、return 指针串联。调度器可以在每个节点之间检查"时间到了没",到了就中断,把控制权还给浏览器。
另外一个容易被忽视的问题是布局抖动(Layout Thrashing)。如果 JavaScript 交替地读取布局属性(offsetWidth、getBoundingClientRect)和修改样式,浏览器会被迫在每次读取时重新计算布局,因为"读取布局属性会强制触发一次同步布局"。在数据驱动模式下,这个问题大部分被框架消化了——框架在更新 DOM 时会尽量批量操作,先改样式再读布局。但如果在自定义组件里不注意,还是会出现。
最后回到开头提到的那个问题:Vue 和 React 在"计算"这件事上的态度差异。
React 把 render 当成计算,计算是开发者的责任。你可以写出非常优雅的、数据模型驱动的 render 函数,也可以在 render 里写一堆 if-else 和逻辑运算。React 不限制你,但这也意味着如果你不在意状态的设计和 render 的拆分,性能问题会很快暴露出来。
Vue 把计算这件事分成了几层:模板负责轻量的展示逻辑(v-if、v-for、表达式),computed 负责派生数据 ,watch 负责副作用,methods 负责事件处理。这种分层强制性地把计算逻辑从模板中剥离出来,某种程度上降低了"写出糟糕 render"的可能性。
两者没有绝对的高下之分。React 的自由度让它在复杂场景下有更强的表达能力,Vue 的约束性让它在常规业务场景下更不容易出问题。但它们背后的核心逻辑是一样的:
精心设计状态模型,最小化状态变更的波及范围,让视图的计算尽可能轻量。
数据驱动模式把"怎么操作 DOM"这个问题交给了框架,但把"怎么设计状态"这个问题留给了开发者。状态设计得好的应用,视图层自然而然就清晰;状态设计得烂的应用,再怎么优化 render 也救不回来。
这大概就是"数据驱动"这个模式的本质——框架管的是机制,开发者管的是设计。机制再好,设计不行,白搭。
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/model-and-view-logic-under-state-driven-mode)