数据驱动视图

MVVM模型

  1. MVVMModel-View-ViewModel 的缩写,它是一种基于前端开发的架构模式,其核心提供对View 和 ViewModel 的双向数据绑定,这使得ViewModel 的状态改变可以自动传递给 View,即所谓的数据双向绑定

  2. MVVM的架构下,View层Model层并没有直接联系,而是通过ViewModel层进行交互。ViewModel层通过双向数据绑定View层Model层连接了起来,使得View层Model层的同步工作完全是自动的。因此开发者只需关注业务逻辑,无需手动操作DOM,复杂的数据状态维护交给MVVM统一来管理

  • M:模型Modeldata中的数据
  • V:视图View,模板代码
  • VM:视图模型ViewModelVue实例

观察发现

  • data中所有的属性,最后都出现在了vm身上
  • vm身上所有的属性及Vue原型身上所有的属性,在Vue模板中都可以直接使用

img

本质事件 + 方法 + 改变数据 = ViewModel

1
<p @click="changeName">{{name}}</p>   // 事件
1
2
3
4
5
6
7
8
9
10
11
data(){   
return {
name:'vue',
list: ['a','b','c']
}
} // Model
methods: {
changeName(){
this.name = "双越"
}
} // 方法

Vue.js中MVVM的体现:

Vue.js的实现方式,是对数据(Model)进行劫持,当数据变动时,数据会触发劫持时绑定的方法,对视图进行更新。

实现细节

Vue是采用Object.definePropertygettersetter,并结合观察者模式来实现数据绑定的。当把一个普通的javascript对象传给Vue实例来作为它的data选项时,Vue将遍历它的属性,用Object.defineProperty将它们转为getter/setter。用户看不到getter/setter,但是在内部它们让Vue追踪依赖,在属性被访问和修改时通知变化。

vue双向绑定图示

  • Observer数据监听器,能够对数据对象的所有属性进行监听,如有变动可拿到最新的值并通知订阅者,内部采用的Obiect.defineProperty的getter和setter来实现。

  • complie指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定Observer和Complie的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应的回调函数

  • Watcher订阅者,作为连接Observer和Complie的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数。

  • Dep消息订阅器,内部维护了一个数组,用来收集订阅者(watcher),数据变动触发notify函数,再调用订阅者的update方法。

    以上过程可总结为:

  • Observer相当于Model层观察vue实例中的data数据,当数据发生变化时,通知Watcher订阅者。
  • Compile指令解析器位于View层,初始化View的视图,将数据变化与更新函数绑定,传给Watcher订阅者。
  • Watcher是整个模型的核心,对应ViewModel层,连接Observer和Compile。所有的Watchers存于Dep订阅器中,Watcher将Observer监听到的数据变化对应相应的回调函数,处理数据,反馈给View层更新界面视图。

参考文章:

深入MVVM模型带你理解Vue.js的双向绑定

MVVM模型简单介绍

Vue响应式

vue是一个MVVM框架,所谓MVVM,最核心的就是数据驱动视图,通俗一点讲就是,用户不直接操作dom,而是通过操作数据,当数据改变时,vue内部监听数据变化然后更新视图。同样,用户在视图上的操作(事件)也会反过来改变数据。而响应式,则是实现数据驱动视图的第一步,即监听数据的变化,使得用户在设置数据时,可以通知vue内部进行视图更新。

即组件data的数据一旦变化,立刻触发视图的更新,是实现数据驱动视图的第一步。

一些小问题

  • 核心API-Object.defineProperty

    通过设定对象属性getter/setter方法来监听数据的变化;

    同时getter也用于收集依赖,而setter在数据变更时通知订阅者更新视图。

  • 如何实现响应式,代码演示。

  • Object.defineProperty的一些缺点

  • proxy存在兼容性问题,且无法polyfill

Vue2.x实现响应式

Object.defineProperty如何实现响应式(代码实现)

image-20220909204915331

上面代码中我们可以看到,Object.defineProperty()的用法就是给一个对象定义一个属性(方法),并提供setget两个内部实现,让我们可以获取或者设置这个属性(方法)。

  • 对于原始类型和对象(如字符串、数字和布尔值等):直接返回

  • 对于引用类型对象:我们就需要对对象进行遍历分别设置对对象的每一个key值使用Object.defineProperty()。注意,这个过程是需要递归调用的,因为我们给出的数据对象可能是多层嵌套的。

  • 对于数组Object.defineProperty()是无法对数组实现响应式的,因此,Vue改写了数组的方法。

核心方法Object.create(prototype),这个方法就是创建一个对象,使他的原型指向参数prototype

Object.defineProperty存在的问题

  1. 无法检测到对象属性的新增或删除

    只能追踪对象已有数据是否被修改,无法追踪新增属性和删除属性。

    目前Vue保证响应式对象新增属性也是响应式的,有两种方式:

    • Vue.set(obj, properName/index, value)

    • 响应式对象的子对象新增属性,可以给子响应式对象重新赋值

      1
      2
      3
      4
      5
      data.location = {
      x: 100,
      y: 100
      }
      data.location = {...data, z: 100}
    • 响应式对象删除属性,可以使用Vue.delete(obj, propertyName/index)或者vue.$delete(obj, propertyName/index); 类似于删除响应式对象子对象的某个属性,也可以重新给子对象赋值来解决。

  2. 不能监听数组的变化

    • Vue在实现数组的响应式时,把无法监听数组的情况通过重写数组的部分方法来实现响应式。

    • 这也只限制在数组的push/pop/shift/unshift/splice/sort/reverse七个方法,其他数组方法及数组的使用则无法检测到,例如如下两种使用方式:

      1
      2
      3
      vm.items[index] = newValue

      vm.items.length--
    • Vue实现数组的响应式并不是重写数组的Array.prototype对应的方法,具体来说就是重新指定要操作数组的prototype,并重写prototype中对应的7个数组方法。

  3. 深度监听,需要一次性递归到底,导致计算量大。

Vue3.x实现响应式

Proxy和Reflect

因为vue2.x版本响应式的实现存在的那些问题,vue官方在3.0版本中完全重写了响应式的实现,改用ProxyReflect代替Object.defineProperty()

Proxy

定义Proxy对象用来给一些基本操作定义自定义行为(比如查找,赋值,枚举,函数调用等等)

基本用法

1
let proxy = new Proxy(target, handler)

上面的参数意义:(注意target可以是原生数组)

  1. target: 用Proxy包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。
  2. handler: 一个对象,其属性是当执行一个操作时定义代理的行为的函数。

Reflect

Reflect 是一个内置的对象,提供拦截 JavaScript 操作的方法。这些方法与proxy的 handlers相同。Reflect不是一个函数对象,因此它是不可构造的。

Refelct对象提供很多方法,这里只介绍实现响应式会用到的几个常用方法:

  1. Reflect.get(): 获取对象身上某个属性的值,类似于 target[name]
  2. Reflect.set(): 将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true
  3. Reflect.has(): 判断一个对象是否存在某个属性,和 in 运算符 的功能完全相同。
  4. Reflect.deleteProperty(): 作为函数的delete操作符,相当于执行 delete target[name]。

于是,我们可以联合ProxyReflect完成响应式监听

Proxy和Reflect实现响应式的优点

  1. Proxy支持监听原生数组
  2. Proxy的获取数据,只会递归到需要获取的层级,不会继续递归
  3. Proxy可以监听数据的手动新增和删除

存在的小问题:主要原因在于ProxyReflect的浏览器兼容问题,且无法被polyfill

Polyfill是一块代码(通常是 Web 上的 JavaScript),用来为旧浏览器提供它没有原生支持的较新的功能。

代码实现

参考文章:

深入Vue响应式原理

最简化Vue的响应式原理

虚拟DOM(Virtual DOM)和 diff算法

虚拟DOM的概念与作用

概念虚拟dom就是一个普通的js对象

  • 是一个用来描述真实dom结构的js对象,因为他不是真实dom,所以才叫虚拟dom。

作用(重点)

  • 传统dom数据发生变化的时候,我们需要不断的去操作dom,才能更新dom的数据,虽然后面出现了模板引擎这种东西,可以让我们一次性去更新多个dom。但模板引擎依旧没有一种可以追踪状态的机制,当引擎内某个数据发生变化时,它依然要操作dom去重新渲染整个引擎。

    提升渲染效率虚拟dom可以很好的跟踪当前dom状态,因为它会根据当前数据生成一个描述当前dom结构的虚拟dom,然后数据发生变化时,又会生成一个新的虚拟dom,而这两个虚拟dom恰恰保存了变化前后的状态。然后通过diff算法,计算出两个前后两个虚拟dom之间的差异,得出一个更新的最优方法(哪些发生改变,就更新哪些),该方法可以很明显的提升渲染效率以及用户体验

  • 跨端性:因为虚拟dom是一个普通的javascript对象,故他不单单只能允许在浏览器端渲染出来的虚拟dom可同时在node环境下或者weex的app环境下允许,有很好的跨端性

虚拟dom的结构是一个对象,下面有6个属性:sel表示当前节点标签名,data内是节点的属性,elm表示当前虚拟节点对应的真实节点,text表示当前节点下的文本,children表示当前节点下的其他标签

Vue中的虚拟dom

目前虚拟dom的类库有多种,常见的有snabbdomvirtual-dom

  • vue以前用的是virtual-dom,从2.x版本后都是使用的snabbdom

核心方法

  • h函数
  • patch函数
  • patchVnode函数
  • updateChildren函数

h函数

概念h函数是在render函数内运行的。

Vue在生命周期的created --> beforeMount阶段会将模板编译成render函数,其实就是将模板编译成h函数所认可的格式放在render函数内,然后当render函数运行的时候,就会生成虚拟dom

总结

  • 首先,代码初次运行,会走生命周期,当生命周期走到createdbeforeMount之间的时候,会编译template模板render函数。然后当render函数运行时,h函数被调用,而h函数内调用了vnode函数生成虚拟dom,并返回生成结果,虚拟dom首次生成
  • 之后,当数据发生变化时会重新编译生成一个新虚拟dom,然后就等待新、旧两个虚拟dom进行对比,这就涉及到下面的diff算法了。

diff算法比较规则

  • diff 比较两个虚拟dom只会在同层级之间进行比较,不会跨层级进行比较。

  • 而用来判断是否是同层级的标准就是

    1. 是否在同一层

    2. 是否有相同的父级

      在这里插入图片描述

  • diff是采用先序深度优先遍历的方式进行节点比较的。即,当比较某个节点时,如果该节点存在子节点,那么会优先比较他的子节点,直到所有子节点全部比较完成,才会开始去比较改节点的下一个同层级节点。

diff比较整体思路

  • 首先开始比较两个vdom时,这两个vdom肯定是都有各自的根节点的,且根节点必定是一个元素。我们首先要比较的肯定是根节点,那我们都知道根节点只有一个,就可以直接进行比较。

    声明,下面所说的sel选择器相同,指的是标签名,id,class都相同。
    例如<div class="abc" id="app">这样一个dom,他的sel是"div#app.abc"

一个节点的比较,通常分为3个部分:

  1. 比较两个节点是否是相同节点,判断是否是相同节点的条件是,**key和sel(选择器)**必须都相同。

    那有的人可能会说了,那我标签没有key怎么办啊,没有key那就是undefined,undefined === undefined 始终为true,所以没有key只需要保证sel相同就行。

    如果不相同,那么执行替换操作(即新增新vnode上的元素,删除旧vnode上的元素,例如,原来是div,新vnode变成了p,那么就是新增p元素,再删除div元素。相当于就是p替换了div)。

    这一步,只有比较根节点时,是在patch函数中进行的非根节点都是在updateChildren函数中执行的。因为根节点只会有一个,可以直接比较,而其他节点会存在多个,需要通过一些算法来判断。

  2. 如果节点相同,那么比较两个节点的属性是否相同。即,节点是否存在文本文本是否相同是否存在子节点,子节点是否相同。这部分主要在patchVnode函数中执行。

    那么,在第二部分,会做哪些事情呢?

    1. 如果存在文本时,更新文本
    2. 如果存在属性时,更新属性
    3. 如果存在子节点时,更新子节点

    那么,如何更新呢,逻辑也很简单,遵循以下规则:

    1. 如果旧vnode上存在,而新vnode上不存在,那么执行删除操作
    2. 如果旧vnode上不存在,而新vnode上存在,那么执行新增操作
    3. 如果新旧vnode上都存在,那么执行替换操作(即,新增新的,删除旧的)。
      • 文本和属性的替换是在这部分完成,而对于子节点,如果新vnode和旧vnode上都存在子节点时,那么会进入第三部分比较,比较子节点的差异。
  3. 主要在updateChildren函数中执行,主要用于比较某个节点下的子节点差异。(请见后文详解)

patch函数

概念:patch函数是比较的开始,相当于是diff的入口

  • 那么既然是开始,说明patch函数比较的肯定就是两个新旧vdom的根节点了,所以,两个vdom直接比较,patch只会触发一次。

作用:比较两个虚拟dom根节点是否相同。

patchVnode函数

概念patchVnode 是用于比较两个相同节点的**子级(文本,或子节点)**的一个函数。

  • 故它的调用总是在sameVnode判断之后。只有判断当前比较的两个vnode相同时才会被执行(两个vnode相同仅仅代表key相同且sel选择器相同)。
  • 在对比之前,会先判断下oldVnode === vNode ,因为如果全等,代表子级肯定也完全相等,那么就没必要对比了,直接return。

作用:对比新旧两个节点,更新dom的子级(子级包含文本或者是子节点)

对比过程

  1. 如果新vnode有text属性

    • 旧vnode是否有子节点,如果有,代表原来是子节点,现在变成文本了,那么删除子节点,并且设置vnode对象的真实dom的text值(使用setTextContent函数)
    • 其他情况不用管,直接设置vnode对象的真实dom的text值
  2. 如果新vnode没有text属性

    • 如果新vnode和旧vnode都存在子节点时。是不是要深度对比两个vnode的子节点啊。这个时候会进入第三步,比较子节点(执行updateChildren)
    • 如果只有新vnode有子节点,老vnode没有,那么很简单,执行添加节点的操作
    • 如果只有旧vnode有子节点,新vnode没有子节点,很明显,要执行删除旧vnode子节点的操作
    • 如果两个vnode上都没有子节点。但旧节点有text,那么很简单,说明原来有文本,现在没有了,清空vnode对应dom的text

updateChildren函数

作用:用于比较新旧两个vnode的子节点。

声明:下文中所指的匹配上,指的就是判断是否是sameVnode,即上文中所说的,key相同,sel选择器相同

比较规则

  1. 首先,会将新旧vnode的子节点(oldCh, Ch)提取出来,并分别加上两个指针**oldStart, oldEnd, newStart, newEnd。分别指向odlCh的第一个节点,oldCh的最后一个节点,Ch的第一个节点,Ch的最后一个节点**。

  2. 比较时,会优先拿oldStart<—>newStart,oldStart<—>newEnd,oldEnd<—>newStart,oldEnd<—>newEnd 两两进行对比。

    • 如果匹配上,那么会将旧节点对应的真实dom移到新节点的位置上,并将匹配上了的指针往中间移动。同时匹配上了的两个节点会继续指向patchVnode函数去进一步比对(指针的移动相当于永远保持指针中间的节点还是尚未匹配状态,已经匹配到的移到指针外面去)
    • 如果上面4种比较都没有匹配上,那么这个时候,有key和没key处理方式就不一样了。
  3. oldStart > oldEnd 或者 newStart > newEnd时,结束对比。此时:

    • 如果是oldStart > oldEnd,代表oldCh都已匹配完成,而此时,如果newStart <= newEnd,那么代表 newStartnewEnd直接的节点为新增节点。那么真实dom会在当前newStartnewEnd之间新增newStartnewEnd中间还未匹配的节点。
    • 如果是newStart > newEnd,代表Ch全都已经匹配完成,而此时,如果oldStartnewEnd之间还有节点,则说明,这些节点是原来存在的,但现在没有了,此时真实dom删除这些节点。
  4. 比较结束

虚拟DOM与Diff算法总结(Vue)

步骤

  1. 比较两个虚拟dom树,对根节点root进行执行patch函数(oldVnode,newVnode)。比较两个根节点是否是相同节点,如果不同,直接替换(新增新的,删除旧的)。

  2. 如果相同,对两个节点执行patchVnode(oldVnode, newVnode),比较属性、文本以及子节点

    • 此时,要么新增,要么删除,要么直接修改文本内容。只有当都存在子节点时,并且oldVnode === newVnode 为false时。会执行updateChildren函数,去进一步比较他们的子节点。
  3. 比较分为3大类。

    1. oldStart === newStart, oldStart === newEnd,oldEnd === newStart,oldEnd === newEnd 这4种情况的比较。如果这4种情况中任何一种匹配。那么会执行patchVnode进一步比较。同时指针往中间移

    2. oldStart > oldEnd 或者 newStart > newEnd时,表示匹配结束。此时,多余的元素删除,新增的元素新增。

    3. 上面几种情况都不匹配,那么这个时候判断key是否存在

      • 存在key时,可以直接通过key去找到节点的原来的位置。如果没有找到,就新增节点;找到了,就移动节点位置。(查找效率非常高)
      • 没有key时,那么压根就不会去原来的节点中查找了,而是直接新增这个节点。这就导致这个节点下的所有子节点都会被重新新增,会出现明显的性能损耗。所以,合理的应用key,也是一种性能上的优化。
  4. 总之一句话。diff的过程,就是一个 patch —> patchVnode —> updateChildren —> patchVnode —> updateChildren —> patchVnode... 这样的一个循环递归的过程

面试回答

虚拟 DOM

虚拟 DOM(Virtual DOM)的简写为 vdom,它是实现 Vue 和 React 的重要基石。

在 jQuery 及更早的时代,我们需要手动调整 DOM,这是一个非常耗费性能的操作,因此需要自行控制 DOM 操作的时机来优化 jQuery 性能。

DOM 更新非常耗时,但 JS 执行速度很快,因此现代前端框架(如 Vue 和 React)都引入了 vdom 的概念:用 JS 模拟 DOM 结构(vnode),新旧 vnode 对比,得出最小的更新范围,最后更新 DOM。

在数据驱动视图的模式下,vdom 能有效控制 DOM 操作。

diff 算法

diff 算法是 vdom 中最核心、最关键的部分。

为了将时间复杂度优化到 O(n),大部分前端框架中实现 diff 算法的思路是:

  • 只比较同一层级,不跨级比较。
  • tag 不相同,则直接删掉重建,不再深度比较。
  • tag 和 key 两者都相同,则认为是相同节点,不再深度比较。

diff算法和数据劫持的共同工作原理

另外,有的同学可能会疑惑。通过这个虚拟dom的diff算法就能精准的知道被更新的地方在哪,然后去更新变动的部分。那我Vue靠这个就够了呀。我为什么还需要数据劫持呢,还需要getter,setter呢。没错,其实单单靠虚拟dom的diff确实是可以实现的。比如react就是这么做的。react精确查找数据的更新就是纯用虚拟dom的diff的。
但是这会产生一个什么问题呢。当项目非常大的时候,dom树是非常复杂的,如果每次一个小小的改动,就要通过diff算法去精确找到改动的地方,那么这个计算量是非常大的,产生的性能损耗也会巨大。这显然是不合理的!

那么vue和react分别是怎么解决这个问题的呢?

  • Vue: 了解Vue的MVVM原理的人应该都清楚,vue通过Object.defineproperty的数据劫持,会劫持到每一个状态数据,给他们加上getter,setter。并且创建一个发布者Dep。同时,会给依赖这个状态数据的每个依赖者添加一个订阅者watcher。这样,当数据发生变化时,会触发对应的setter,从而Dep会发布通知,通知每一个订阅者watcher,然后watcher更新对应的数据。但是,如果任何一个数据的依赖我都增加一个watcher,那么项目中的watcher数量是会非常庞大的,这会导致细粒度太高,带来内存和依赖关系维护的巨大消耗。这种情况下,vue采用响应式 + diff 的方式,即通过响应式的getter,setter快速知道数据的变化发生在哪个组件中,然后组件内部再通过 diff 的方式去获取更详细的更新情况,并更新数据。

  • React: 通过他的一个生命周期函数shouldComponentUpdate实现的。通过手动在这个生命周期函数中判断当前组件的数据是否有发生变化,来决定当前组件是否需要更新。这样,没有发生状态数据变化的组件就不需要进行diff,进而缩小diff的范围。

参考文章:

Vue 虚拟dom和diff算法详解

模板编译

Vue 模板被编译成什么

  • Vue 的模板不是 html,因为它有指令、插值、JS 表达式,能实现判断、循环;
  • html 是标签语言,只有 JS 才能实现判断、循环。

因此,模板一定是转换为某种 JS 代码,即编译模板。

Vue 模板编译过程

模板编译流程

  • 模板编译为 render 函数,执行 render 函数返回 vnode;
  • 基于 vnode 再执行 patch 和 diff;
  • 使用 Webpack 时,vue-loader 会在开发环境下编译模板(目前业内主流);否则会在浏览器运行中编译(单独在页面中引入 vue.js 使用)。

模板编译流程图

template --> AST --> render函数 --> 创建虚拟dom --> diff算法更新虚拟 dom --> 产生、更新真实节点

image.png

模板编译成渲染函数的步骤

将模板编译成渲染函数可以分两个步骤,先将模板解析成 AST(Abstract Syntax Tree,抽象语法树),然后再使用 AST 生成渲染函数。但是由于静态节点不需要总是重新渲染,所以在生成 AST 之后、生成渲染函数之前这个阶段,可以做一个操作,那就是遍历一遍 AST,给所有静态节点做一个标记,这样在虚拟 DOM 中更新节点时,如果发现节点有这个标记,就不会重新渲染它。

所以,在大体逻辑上,模板编译分三部分内容:

  • 将模板解析为 AST
  • 遍历 AST 标记静态节点
  • 使用 AST 生成渲染函数

这三部分内容在模板编译中分别抽象出三个模块来实现各自的功能,分别是:

  • 解析器
  • 优化器
  • 代码生成器

image.png

  1. 解析器 —— 将模板解析成 AST

    在解析器内部,分成了很多小解析器,其中包括过滤器解析器(解析过滤器)、文本解析器(解析带变量的文本)和 HTML 解析器(核心)。

    AST 和 vnode 类似,都是使用 JavaScript 中的对象来表示节点。

  2. 优化器 —— 标记静态节点

    什么是静态节点?没有使用任何变量,后续不会发生变化的节点。比如:

    1
    <p>我是一个静态节点,因为我没用到什么变量。</p>

    为什么要标记静态节点?避免做一些无用功,因为静态节点不会随着状态的变化而变化,在虚拟 DOM patch 时可以跳过。

  3. 代码生成器——将 AST 转换成“代码字符串”

    就是我们要输入到渲染函数中的内容,之后生成的代码字符串将用于渲染函数。

详细内容请参阅:

Vue模板是如何编译的

别再问我Vue模板是怎么编译了

Vue和React的简单对比

相同点

  • 都有组件化思想
  • 都支持服务器端渲染
  • 都有Virtual DOM(虚拟dom
  • 数据驱动视图
  • 都有支持native的方案:VueweexReactReact native
  • 都有自己的构建工具Vuevue-cliReactCreate React App

不同点

  • 数据流向的不同react从诞生开始就推崇单向数据流,而Vue是双向数据流
  • 数据变化的实现原理不同react使用的是不可变数据,而Vue使用的是可变的数据
  • 组件化通信的不同。react中我们通过使用回调函数来进行通信的,而Vue中子组件向父组件传递消息有两种方式:事件和回调函数
  • diff算法不同。react主要使用diff队列保存需要更新哪些DOM,得到patch树,再统一操作批量更新DOM。Vue 使用双向指针,边对比,边更新DOM。