首先了解一下浏览器的渲染原理《How Browsers Work》译文由于最近在探索框架性能,各框架的正确使用场景,对框架的数据绑定原理做了如下的总结:
1、Angular
Angular 的数据观测采用的是脏检查(dirty checking)机制。每一个指令都会有一个对应的用来观测数据的对象,叫做 watcher;一个作用域中会有很多个 watcher。每当界面需要更新时,Angular 会遍历当前作用域里的所有 watcher,对它们一一求值,然后和之前保存的旧值进行比较。如果求值的结果变化了,就触发对应的更新,这个过程叫做 digest cycle。
脏检查有两个问题:
1)任何数据变动都意味着当前作用域的每一个 watcher 需要被重新求值,因此当 watcher 的数量庞大时,应用的性能就不可避免地受到影响,并且很难优化;
2)当数据变动时,框架并不能主动侦测到变化的发生,需要手动触发 digest cycle 才能触发相应的 DOM 更新。
Angular 通过在 DOM 事件处理函数中自动触发 digest cycle 部分规避了这个问题,但还是有很多情况需要用户手动进行触发。脏检查即一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。而常规的 set, get 的方式则会强加许多限制;脏检查可以实现批处理完数据之后,再去统一更新 view;脏检查其实比 GET/SET 更容易实现。脏检查是个单向的检查流程(请不要和双向绑定发生混淆),可以实现_任意复杂度的表达式支持。而 get/ set 的方式则需要处理复杂依赖链,基本上表达式支持都是阉割的. 很显然,脏检查是低效的,它的效率基本上取决于你绑定的观察者数量。然而结合这种类 mvvm 系统中,他又是高效的。因为监听模式带来了 dom 的局部更新,而 dom 操作恰恰又是隐藏的性能瓶颈所在。
2、VueJS 将原生的数据改造成 “可观察对象”。一个可观察对象可以被取值,也可以被赋值, 在 watcher 的求值过程中,每一个被取值的可观察对象都会将当前的 watcher 注册为自己的一个订阅者,并成为当前 watcher 的一个依赖。 当一个被依赖的可观察对象被赋值时,它会通知所有订阅自己的 watcher 重新求值,并触发相应的更新。
依赖收集的优点在于可以精确、主动地追踪数据的变化,不存在上述提到的脏检查的两个问题。但传统的依赖收集实现,比如 Knockout,通常需要包裹原生数据来制造可观察对象,在取值和赋值时需要采用函数调用的形式,在进行数据操作时写法繁琐,不够直观;同时,对复杂嵌套结构的对象支持也不理想。
Vue.js 利用了 ES5 的 Object.defineProperty 方法,直接将原生数据对象的属性改造为 getter 和 setter,在这两个函数内部实现依赖的收集和触发,而且完美支持嵌套的对象结构。对于数组,则通过包裹数组的可变方法(比如 push)来监听数组的变化。这使得操作 Vue. js 的数据和操作原生对象几乎没有差别[注:在添加/删除属性,或是修改数组特定位置元素时,需要调用特定的函数,如 obj.$add(key, value) 才能触发更新。这是受 ES5 的语言特性所限。],数据操作的逻辑更为清晰流畅,和第三方数据同步方案的整合也更为方便。
异步批量 DOM 更新:当大量数据变动时,所有受到影响的 watcher 会被推送到一个队列中,并且每个 watcher 只会推进队列一次。这个队列会在进程 的下一个 “tick” 异步执行。这个机制可以避免同一个数据多次变动产生的多余 DOM 操作,也可以保证所有的 DOM 写操作在一起执行,避免 DOM 读写切换可能导致的 layout。 动画系统:Vue.js 提供了简单却强大的动画系统,当一个元素的可见性变化时,用户不仅可以很简单地定义对应的 CSS Transition 或 Animation 效果,还可以利用丰富的 JavaScript 钩子函数进行更底层的动画处理。 可扩展性:除了自定义指令、过滤器和组件,Vue.js 还提供了灵活的 mixin 机制,让用户可以在多个组件中复用共同的特性。
在大型的应用中,为了分工、复用和可维护性,我们不可避免地需要将应用抽象为多个相对独立的模块。在较为传统的开发模式中,我们只有在考虑复用时才会将某一部分做成组件;但实际上,应用类 UI 完全可以看作是全部由组件树构成的
3、ReactJS Web 界面由 DOM 树来构成,当其中某一部分发生变化时,其实就是对应的某个 DOM 节点发生了变化。在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。 即给定任意两棵树,找到最少的转换步骤。但是标准的 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。要达到每次界面都可以整体刷新界面的目的,势必需要对算法进行优化。这看上去非常有难度,然而 Facebook 工程师却做到了,他们结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n),两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构; 算法上的优化是 React 整个界面 Render 的基础,事实也证明这两个假设是合理而精确的,保证了整体界面构建的性能,对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。 React 的基本思维模式是每次有变动就整个重新渲染整个应用。如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作… 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。 我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗: innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size) Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change) Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM:它保证了 1)不管你的数据变化多少,每次重绘的性能都可以接受;2) 你依然可以用类似 innerHTML 的思路去写你的应用。
总结: Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求,性能比较也要看场合
在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。
初始渲染:Virtual DOM > 脏检查 >= 依赖收集
小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化
大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化 .
- 本文作者: luckyship
- 本文链接: https://luckyship.github.io/2021/09/20/2021-09-20-web-framed-diff/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!