零. MVVM的理解
回答思路:先聊下 MVC,再聊下 MVVM 的定义,最后进行对比。
View 传送指令到 ControllerController 完成业务逻辑后,要求 Model 改变状态Model 将新的数据发送到 View,用户得到反馈
所有通信都是单向的。MVC 接收用户指令,可以先通过 View 来接收,然后传递给 Controller,也可以直接通过 Controller 来接收指令。
ViewModel 和 View 之间是通过双向绑定来实现数据的变更ViewModel 和 Model 之间是浏览器通过 ajax 跟服务器相互通信的过程
这两个通信是双向的,且 View 和 Model 之间没有通信。
MVVM 模式的出现是因为很多后端的代码放到了前端(大前端的到来),前端的代码可维护性、可扩展性以及安全性出现了问题,随着前端框架的演变才有了 MVVM 模式,MVVM 模式和 MVC 模式主要区别在于让开发者的注意力从对 DOM 的操作上,转移到对数据的管理上,即数据是什么,视图就展示什么,使前后端分离更容易,并大大提升了开发效率和代码的可维护性。
一. 双向绑定与数据劫持
Vue.js是通过数据劫持以及结合发布者-订阅者来实现双向绑定的,数据劫持是利用ES5的Object.defineProperty(obj, key, val)来劫持各个属性的的setter以及getter,在数据变动时发布消息给订阅者,从而触发相应的回调来更新视图。
双向数据绑定,简单点来说分为三个部分:
Observer:观察者,这里的主要工作是递归地监听对象上的所有属性,在属性值改变的时候,触发相应的watcher。
Watcher:订阅者,当监听的数据值修改时,执行响应的回调函数(Vue里面的更新模板内容)。
Dep:订阅管理器,连接Observer和Watcher的桥梁,每一个Observer对应一个Dep,它内部维护一个数组,保存与该Observer相关的Watcher。
反向是页面数据的变化映射到 data 中,通过 input 事件监听 input 框数据的改变,JS 得到通知再赋值给 data,只是 VM 框架使手动的过程自动化了。
正向是数据驱动页面,通过 Object.defineProperty() 这个核心 API,将所有数据变成响应式数据,当访问到一个响应式数据时,就会触发它的 getter 函数,收集依赖,当一个响应式数据变化时,就会触发 setter 函数,通知依赖使视图得到更新。
二. 几种实现双向绑定的做法
目前几种主流的mvc(vm)框架都实现了单向数据绑定,而我所理解的双向数据绑定无非就是在单向绑定的基础上给可输入元素(input、textare等)添加了change(input)事件,来动态修改model和 view,并没有多高深。所以无需太过介怀是实现的单向或双向绑定。
实现数据绑定的做法有大致如下几种:
发布者-订阅者模式(backbone.js)
脏值检查(angular.js)
数据劫持(vue.js)
01
发布者-订阅者模式
一般通过sub, pub的方式实现数据和视图的绑定监听,更新数据方式通常做法是vm.set(‘property’, value)。这种方式现在毕竟太low了,我们更希望通过vm.property = value这种方式更新数据,同时自动更新视图,于是有了下面两种方式
02
脏值检查
angular.js 是通过脏值检测的方式比对数据是否有变更,来决定是否更新视图,最简单的方式就是通过setInterval()定时轮询检测数据变动,当然Google不会这么low,angular只有在指定的事件触发时进入脏值检测,大致如下:
XHR响应事件 ( $http )
浏览器Location变更事件 ( $location )
Timer事件( $timeout , $interval )
执行 $digest() 或 $apply()
03
数据劫持
vue.js 则是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的setter,getter,在数据变动时发布消息给订阅者,触发相应的监听回调。
三. 思路整理
已经了解到vue是通过数据劫持的方式来做数据绑定的,其中最核心的方法便是通过Object.defineProperty()来实现对属性的劫持,达到监听数据变动的目的,无疑这个方法是本文中最重要、最基础的内容之一。
整理了一下,要实现mvvm的双向绑定,就必须要实现以下几点:1、实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者2、实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数3、实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图4、mvvm入口函数,整合以上三者
上述流程如图所示:
01
实现Observer
ok, 思路已经整理完毕,也已经比较明确相关逻辑和模块功能了,let’s do it。我们知道可以利用Obeject.defineProperty()来监听属性变动,那么将需要observe的数据对象进行递归遍历,包括子属性对象的属性,都加上setter和getter。这样的话,给这个对象的某个值赋值,就会触发setter,那么就能监听到了数据变化。相关代码可以是这样:
var data = {name: ‘kindeng’};observe(data);data.name = ‘dmq’; // 哈哈哈,监听到值变化了 kindeng –> dmqfunction observe(data) { if (!data || typeof data !== ‘object’) { return; } // 取出所有属性遍历 Object.keys(data).forEach(function(key) { defineReactive(data, key, data[key]); });};function defineReactive(data, key, val) { observe(val); // 监听子属性 Object.defineProperty(data, key, { enumerable: true, // 可枚举 configurable: false, // 不能再define get: function() { return val; }, set: function(newVal) { console.log(‘哈哈哈,监听到值变化了 ‘, val, ‘ –> ‘, newVal); val = newVal; } });}
这样我们已经可以监听每个数据的变化了,那么监听到变化之后就是怎么通知订阅者了,所以接下来我们需要实现一个消息订阅器,很简单,维护一个数组,用来收集订阅者,数据变动触发notify,再调用订阅者的update方法,代码改善之后是这样:
// … 省略function defineReactive(data, key, val) { var dep = new Dep(); observe(val); // 监听子属性 Object.defineProperty(data, key, { // … 省略 set: function(newVal) { if (val === newVal) return; console.log(‘哈哈哈,监听到值变化了 ‘, val, ‘ –> ‘, newVal); val = newVal; dep.notify(); // 通知所有订阅者 } });}function Dep() { this.subs = [];}Dep.prototype = { addSub: function(sub) { this.subs.push(sub); }, notify: function() { this.subs.forEach(function(sub) { sub.update(); }); }};
那么问题来了,谁是订阅者?怎么往订阅器添加订阅者?没错,上面的思路整理中我们已经明确订阅者应该是Watcher, 而且var dep = new Dep();是在defineReactive方法内部定义的,所以想通过dep添加订阅者,就必须要在闭包内操作,所以我们可以在getter里面动手脚:
// Observer.js// …省略Object.defineProperty(data, key, { get: function() { // 由于需要在闭包内添加watcher,所以通过Dep定义一个全局target属性,暂存watcher, 添加完移除 Dep.target && dep.addSub(Dep.target); return val; } // … 省略});// Watcher.jsWatcher.prototype = { get: function(key) { Dep.target = this; this.value = data[key]; // 这里会触发属性的getter,从而添加订阅者 Dep.target = null; }}
这里已经实现了一个Observer了,已经具备了监听数据和数据变化通知订阅者的功能,完整代码。那么接下来就是实现Compile了
02
实现Compile
compile主要做的事情是解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图,如图所示:
因为遍历解析的过程有多次操作dom节点,为提高性能和效率,会先将跟节点el转换成文档碎片fragment进行解析编译操作,解析完成,再将fragment添加回原来的真实dom节点中
function Compile(el) { this.$el = this.isElementNode(el) ? el : document.querySelector(el); if (this.$el) { this.$fragment = this.node2Fragment(this.$el); this.init(); this.$el.appendChild(this.$fragment); }}Compile.prototype = { init: function() { this.compileElement(this.$fragment); }, node2Fragment: function(el) { var fragment = document.createDocumentFragment(), child; // 将原生节点拷贝到fragment while (child = el.firstChild) { fragment.appendChild(child); } return fragment; }};
compileElement方法将遍历所有节点及其子节点,进行扫描解析编译,调用对应的指令渲染函数进行数据渲染,并调用对应的指令更新函数进行绑定,详看代码及注释说明:
Compile.prototype = { // … 省略 compileElement: function(el) { var childNodes = el.childNodes, me = this; [].slice.call(childNodes).forEach(function(node) { var text = node.textContent; var reg = /\{\{(.*)\}\}/; // 表达式文本 // 按元素节点方式编译 if (me.isElementNode(node)) { me.compile(node); } else if (me.isTextNode(node) && reg.test(text)) { me.compileText(node, RegExp.$1); } // 遍历编译子节点 if (node.childNodes && node.childNodes.length) { me.compileElement(node); } }); }, compile: function(node) { var nodeAttrs = node.attributes, me = this; [].slice.call(nodeAttrs).forEach(function(attr) { // 规定:指令以 v-xxx 命名 // 如 <span v-text=”content”></span> 中指令为 v-text var attrName = attr.name; // v-text if (me.isDirective(attrName)) { var exp = attr.value; // content var dir = attrName.substring(2); // text if (me.isEventDirective(dir)) { // 事件指令, 如 v-on:click compileUtil.eventHandler(node, me.$vm, exp, dir); } else { // 普通指令 compileUtil[dir] && compileUtil[dir](node, me.$vm, exp); } } }); }};// 指令处理集合var compileUtil = { text: function(node, vm, exp) { this.bind(node, vm, exp, ‘text’); }, // …省略 bind: function(node, vm, exp, dir) { var updaterFn = updater[dir ‘Updater’]; // 第一次初始化视图 updaterFn && updaterFn(node, vm[exp]); // 实例化订阅者,此操作会在对应的属性消息订阅器中添加了该订阅者watcher new Watcher(vm, exp, function(value, oldValue) { // 一旦属性值有变化,会收到通知执行此更新函数,更新视图 updaterFn && updaterFn(node, value, oldValue); }); }};// 更新函数var updater = { textUpdater: function(node, value) { node.textContent = typeof value == ‘undefined’ ? ” : value; } // …省略};
这里通过递归遍历保证了每个节点及子节点都会解析编译到,包括了{{}}表达式声明的文本节点。指令的声明规定是通过特定前缀的节点属性来标记,如<span v-text=”content” other-attr中v-text便是指令,而other-attr不是指令,只是普通的属性。
监听数据、绑定更新函数的处理是在compileUtil.bind()这个方法中,通过new Watcher()添加回调来接收数据变化的通知
至此,一个简单的Compile就完成了。接下来要看看Watcher这个订阅者的具体实现了
03
实现Watcher
Watcher订阅者作为Observer和Compile之间通信的桥梁,主要做的事情是:1、在自身实例化时往属性订阅器(dep)里面添加自己2、自身必须有一个update()方法3、待属性变动dep.notice()通知时,能调用自身的update()方法,并触发Compile中绑定的回调,则功成身退。如果有点乱,可以回顾下前面的思路整理
function Watcher(vm, exp, cb) { this.cb = cb; this.vm = vm; this.exp = exp; // 此处为了触发属性的getter,从而在dep添加自己,结合Observer更易理解 this.value = this.get(); }Watcher.prototype = { update: function() { this.run(); // 属性值变化收到通知 }, run: function() { var value = this.get(); // 取到最新值 var oldVal = this.value; if (value !== oldVal) { this.value = value; this.cb.call(this.vm, value, oldVal); // 执行Compile中绑定的回调,更新视图 } }, get: function() { Dep.target = this; // 将当前订阅者指向自己 var value = this.vm[exp]; // 触发getter,添加自己到属性订阅器中 Dep.target = null; // 添加完毕,重置 return value; }};// 这里再次列出Observer和Dep,方便理解Object.defineProperty(data, key, { get: function() { // 由于需要在闭包内添加watcher,所以可以在Dep定义一个全局target属性,暂存watcher, 添加完移除 Dep.target && dep.addDep(Dep.target); return val; } // … 省略});Dep.prototype = { notify: function() { this.subs.forEach(function(sub) { sub.update(); // 调用订阅者的update方法,通知变化 }); }};
实例化Watcher的时候,调用get()方法,通过Dep.target = watcherInstance标记订阅者是当前watcher实例,强行触发属性定义的getter方法,getter方法执行的时候,就会在属性的订阅器dep添加当前watcher实例,从而在属性值有变化的时候,watcherInstance就能收到更新通知。
ok, Watcher也已经实现了,最后来讲讲MVVM入口文件的相关逻辑和实现吧,相对就比较简单了~
04
实现MVVM
MVVM作为数据绑定的入口,整合Observer、Compile和Watcher三者,通过Observer来监听自己的model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起Observer和Compile之间的通信桥梁,达到数据变化 -> 视图更新;视图交互变化(input) -> 数据model变更的双向绑定效果。
一个简单的MVVM构造器是这样子:
function MVVM(options) { this.$options = options; var data = this._data = this.$options.data; observe(data, this); this.$compile = new Compile(options.el || document.body, this)}
但是这里有个问题,从代码中可看出监听的数据对象是options.data,每次需要更新视图,则必须通过var vm = new MVVM({data:{name: ‘kindeng’}}); vm._data.name = ‘dmq’;这样的方式来改变数据。
显然不符合我们一开始的期望,我们所期望的调用方式应该是这样的:var vm = new MVVM({data: {name: ‘kindeng’}}); vm.name = ‘dmq’;
所以这里需要给MVVM实例添加一个属性代理的方法,使访问vm的属性代理为访问vm._data的属性,改造后的代码如下:
function MVVM(options) { this.$options = options; var data = this._data = this.$options.data, me = this; // 属性代理,实现 vm.xxx -> vm._data.xxx Object.keys(data).forEach(function(key) { me._proxy(key); }); observe(data, this); this.$compile = new Compile(options.el || document.body, this)}MVVM.prototype = { _proxy: function(key) { var me = this; Object.defineProperty(me, key, { configurable: false, enumerable: true, get: function proxyGetter() { return me._data[key]; }, set: function proxySetter(newVal) { me._data[key] = newVal; } }); }};
这里主要还是利用了Object.defineProperty()这个方法来劫持了vm实例对象的属性的读写权,使读写vm实例的属性转成读写了vm._data的属性值,达到鱼目混珠的效果,哈哈。
至此,全部模块和功能已经完成了,如本文开头所承诺的两点。一个简单的MVVM模块已经实现,其思想和原理大部分来自经过简化改造的vue源码
四. Diff操作
diff算法的本质是找出两个对象之间的差异,目的是尽可能复用节点。
01
Diff算法包括一下几个步骤
用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中
当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较(diff),记录两棵树差异
把2所记录的差异应用到步骤1所构建的真正的DOM树上(patch),视图就更新了
我们先看一下简单的diff是怎么设计的
逐个遍历newVdom的节点,找到它在oldVdom中的位置,如果找到了就移动对应的DOM元素,如果没找到说明是新增节点,则新建一个节点插入。遍历完成之后如果oldVdom中还有没处理过的节点,则说明这些节点在newVdom中被删除了,删除它们即可。
02
Vnode对象实现
此处说到的对象其实就对应 vue中的 virtual dom,即使用 js 对象来表示页面中的 dom 结构。
<div id=’app’> <span id=’child’>1</span> </div>
其实仔细思考下,一个dom节点主要包含三个部分
自身的标签名(div)
自身的属性(id=’app’)
子节点(span)
所以我们可以设计如下的对象结构表示一个 dom 节点
const vnode = { tag:’div’, attrs:{id:’app’}, children:[{ tag:’span’,attrs:{id:’child’},children:[‘1′]}]}
当用户对界面进行操作,比如把 div 的 id 改为 app2 ,将子节点 span 的文本子节点 1 改为 2,那么我们可以得到如下 vnode
const vnode2 = { tag:’div’, attrs:{id:’app2′}, children:[{ tag:’span’,attrs:{id:’child’},children:[‘2’]}]}
上文说了这个结论,再看下
diff算法的本质是找出两个对象之间的差异,目的是尽可能复用节点。
那么我们运行 diff (vnode,vnode2),就能知道 vnode 和 vnode2 之间的差异如下:
div 的 id 改为 app2
span 的文本子节点 1 改为 2
知道了差异部分,我们就能更新视图了,伪代码如下
document.getElementById(“app”).setAttribute(‘id’, ‘app2′)// id 改为 app2document.getElementById(“child”).firstChild.textContent =’2’ //1 改为 2
03
Diff算法”两部分”
再思考下,当我们改变一个节点的时候,我们其实主要改了以下部分
自身的属性(style 、class等等)
子节点
那么 diff 算法可以抽象为 两部分
function diff(vnode,newVnode){ diffAttr(vnode.attr,newVnode.attr) diffChildren(vnode.children,newVnode.children)}
vue之前的源码是采用 先 diff,得到差异,然后根据差异在去 patch 真实 dom,也就是分两步骤
diff
patch
但是这样性能会有损失,因为 diff 过程中会遍历一次整棵树,patch 的时候又会遍历整棵树,其实这两次遍历可以合并成一次,也就是在diff的同时进行patch。
所以我们把流程改为:
function patchVnode(oldVnode, vnode, parentElm){ patchAttr(oldVnode.attr, vnode.attr, parentElm) patchChildren(parentElm, oldVnode.children, vnode.children)}
1. patchAttr
printf(“hello world!”);function patchAttr(oldVnode = {}, vnode = {}, parentElm) { each(oldVnode, (key, val) => { //遍历 oldVnode 看newTreeAttr 是否还有对应的属性 if (vnode[key]) { val !== vnode[key] && setAttr(parentElm, key, vnode[key]) } else { parentElm.removeAttribute(key) } }) each(vnode, (key, val) => {//看 oldVnode 是否还有对应的属性,没有就新增 !oldVnode[key] && setAttr(parentElm, key, val) })}function each(obj, fn) {//遍历对象 if (Object.prototype.toString.call(obj) !== ‘[object Object]’) { console.error(‘只能遍历对象!’) return } for (var key in obj) { if (obj.hasOwnProperty(key)) { var val = obj[key]; fn(key, val) } }}function setAttr(node, key, value) { switch (key) { case ‘style’: each(value, (key, val) => { node.style[key] = val }) break case ‘value’: var tag = node.tag || ” tag = tag.toLowerCase() if ( tag === ‘input’ || tag === ‘textarea’ ) { node.value = value } else { // if it is not a input or textarea, use `setAttribute` to set node.setAttribute(key, value) } break default: node.setAttribute(key, value) break }}
该函数主要做了两件事:
遍历 oldVnode 看 newTreeAttr 是否还有对应的属性
如果有并且不相等的,修改对应的属性,
没有的话,直接删除对应的属性
遍历oldVnode, 是否还有对应的属性,没有就新增
2. patchChildren
function patchChildren(parentElm, oldCh, newCh) { let oldStartIdx = 0; let oldEndIdx = oldCh.length – 1; let oldStartVnode = oldCh[0]; let oldEndVnode = oldCh[oldEndIdx]; let newStartIdx = 0; let newEndIdx = newCh.length – 1; let newStartVnode = newCh[0]; let newEndVnode = newCh[newEndIdx]; let oldKeyToIdx, idxInOld, elmToMove, refElm; while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (!oldStartVnode) { oldStartVnode = oldCh[ oldStartIdx]; } else if (!oldEndVnode) { oldEndVnode = oldCh[–oldEndIdx]; } else if (sameVnode(oldStartVnode, newStartVnode)) { //旧首 和 新首相同 patchVnode(oldStartVnode.elm, oldStartVnode, newStartVnode); oldStartVnode = oldCh[ oldStartIdx]; newStartVnode = newCh[ newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) { //旧尾 和 新尾相同 patchVnode(oldEndVnode.elm, oldEndVnode, newEndVnode); oldEndVnode = oldCh[–oldEndIdx]; newEndVnode = newCh[–newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) { //旧首 和 新尾相同,将旧首移动到 最后面 patchVnode(oldStartVnode.elm, oldStartVnode, newEndVnode); nodeOps.insertBefore(parentElm, oldStartVnode.elm, oldEndVnode.elm.nextSibling)//将 旧首 移动到最后一个节点后面 oldStartVnode = oldCh[ oldStartIdx]; newEndVnode = newCh[–newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) {//旧尾 和 新首相同 ,将 旧尾 移动到 最前面 patchVnode(oldEndVnode.elm, oldEndVnode, newStartVnode); nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); oldEndVnode = oldCh[–oldEndIdx]; newStartVnode = newCh[ newStartIdx]; } else {//首尾对比 都不 符合 sameVnode 的话 //1. 尝试 用 newCh 的第一项在 oldCh 内寻找 sameVnode let elmToMove = oldCh[idxInOld]; if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null; if (!idxInOld) {//如果 oldCh 不存在 sameVnode 则直接创建一个 nodeOps.createElm(newStartVnode, parentElm); newStartVnode = newCh[ newStartIdx]; } else { elmToMove = oldCh[idxInOld]; if (sameVnode(elmToMove, newStartVnode)) { patchVnode(elmToMove, newStartVnode); oldCh[idxInOld] = undefined; nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm); newStartVnode = newCh[ newStartIdx]; } else { nodeOps.createElm(newStartVnode, parentElm); newStartVnode = newCh[ newStartIdx]; } } } } if (oldStartIdx > oldEndIdx) { refElm = (newCh[newEndIdx 1]) ? newCh[newEndIdx 1].elm : null; nodeOps.addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx); } else if (newStartIdx > newEndIdx) { nodeOps.removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); }}
上述代码的本质是找出两个数组的差异:
举个栗子
旧数组[a,b,c,d]
新数组[e,f,g,h]
怎么找出新旧数组之间的差异呢? 我们约定以下名词 – 旧首(旧数组的第一个元素) – 旧尾(旧数组的最后一个元素) – 新首(新数组的第一个元素) – 新尾(新数组的最后一个元素)
3. 一些工具函数
sameVnode–用于判断节点是否应该复用,这里做了一些简化,实际的diff算法复杂些,这里只用tag 和 key 相同,我们就复用节点,执行patchVnode,即对节点进行修改
function sameVnode(a, b) { return a.key === b.key && a.tag === b.tag}
createKeyToOldIdx–建立key-index的索引,主要是替代遍历,提升性能
function createKeyToOldIdx(children, beginIdx, endIdx) { let i, key const map = {} for (i = beginIdx; i <= endIdx; i) { key = children[i].key if (isDef(key)) map[key] = i } return map}
旧首 和 新首 对比
if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode.elm, oldStartVnode, newStartVnode); oldStartVnode = oldCh[ oldStartIdx]; newStartVnode = newCh[ newStartIdx]; }
旧尾 和 新尾 对比
if (sameVnode(oldEndVnode, newEndVnode)) { //旧尾 和 新尾相同 patchVnode(oldEndVnode.elm, oldEndVnode, newEndVnode); oldEndVnode = oldCh[–oldEndIdx]; newEndVnode = newCh[–newEndIdx]; }
旧首 和 新尾 对比
if (sameVnode(oldStartVnode, newEndVnode)) { //旧首 和 新尾相同,将旧首移动到 最后面 patchVnode(oldStartVnode.elm, oldStartVnode, newEndVnode); nodeOps.insertBefore(parentElm, oldStartVnode.elm, oldEndVnode.elm.nextSibling) oldStartVnode = oldCh[ oldStartIdx]; newEndVnode = newCh[–newEndIdx]; }
旧尾 和 新首 对比,将 旧尾 移动到 最前面
if (sameVnode(oldEndVnode, newStartVnode)) {//旧尾 和 新首相同 ,将 旧尾 移动到 最前面 patchVnode(oldEndVnode.elm, oldEndVnode, newStartVnode); nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); oldEndVnode = oldCh[–oldEndIdx]; newStartVnode = newCh[ newStartIdx]; }
首尾对比 都不 符合 sameVnode 的话
尝试 用 newCh 的第一项在 oldCh 内寻找 sameVnode,如果在 oldCh 不存在对应的 sameVnode ,则直接创建一个,存在的话则判断
符合 sameVnode,则移动 oldCh 对应的 节点
不符合 sameVnode ,创建新节点
最后 通过 oldStartIdx > oldEndIdx ,来判断 oldCh 和 newCh 哪一个先遍历完成
oldCh 先遍历完成,则证明 newCh 还有多余节点,需要新增这些节点
newCh 先遍历完成,则证明 oldCh 还有多余节点,需要删除这些节点
04
总结
diff 算法的本质是找出两个对象之间的差异
diff 算法的核心是子节点数组对比,思路是通过首尾两端对比
key 的作用 主要是:
决定节点是否可以复用
建立key-index的索引,主要是替代遍历,提升性能