WdBly Blog

懂事、有趣、保持理智

WdBly Blog

懂事、有趣、保持理智

周维 | Jim

603927378@qq.com

vue和angular的双向绑定原理分析

从vue和angular分析两种不同的双向绑定方法

vue的双向绑定模式

vue是一款典型的使用mvvm设计模式的前端开发框架,通过数据劫持来实现双向绑定。

大致流程图:

image.png

1:初始化时首先会对数据进行劫持 然后生成多个订阅器,每个订阅器对象都会有一个唯一标识和一个数组,用于向订阅器中添加订阅者。

var uid = 0; function Dep() { this.id = uid++; this.subs = []; } Dep.target = null;

为了保证订阅器和数据的一一对应,我们在数据劫持时生成对应的订阅器:

defineReactive: function(data, key, val) { var dep = new Dep(); var childObj = observe(val); //深层次的对象劫持 Object.defineProperty(data, key, { ... }); }

eg:

data:{ test:213, //dep1 test2:{ //dep2 test3:324, //dep3 test4:{ //dep4 test5:6346 //dep5 } } }

2:complie 将根节点(传入的el节点)劫持(vue2.x使用的Virtual dom),将之转化为fragment(文档碎片),后递归处理所有绑定的指令,更新初始视图,生成watcher

// 将原生节点拷贝到fragment //appendChild() 方法从一个元素向另一个元素中移动元素。 while (child = el.firstChild) { //先判断再赋值 fragment.appendChild(child); }

处理绑定的指令

[].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)) { //()为一个分组,RegExp.$1拿到第一个分组的内容 me.compileText(node, RegExp.$1); } if (node.childNodes && node.childNodes.length) { me.compileElement(node); } });

更新初始视图

bind: function(node, vm, exp, dir) { var updaterFn = updater[dir + 'Updater']; updaterFn && updaterFn(node, this._getVMVal(vm, exp)); ... }

生成watcher,watcher用来处理视图的变化

bind: function(node, vm, exp, dir) { ... new Watcher(vm, exp, function(value, oldValue) { updaterFn && updaterFn(node, value, oldValue); }); }

3:watcher干了什么?
(1)每生成一个watcher都会调用自身的get方法,将自己添加到Dep.target,并触发相应的被劫持数据的get勾子,然后将Dep.target设为null
(2)watcher实例有一个updata方法用于执行complie的回调。

这里的target就是用来控制不是任何时候触发get都会向dep中添加watcher,只有在watcher生成时会添加。

watcher的get 方法

get: function() { Dep.target = this; var value = this.getter.call(this.vm, this.vm); Dep.target = null; return value; },

相应数据的get勾子

get: function() { if (Dep.target) { dep.depend(); } return val; }, depend: function() { Dep.target.addDep(this); } //这个时候的Dep.target就是代表watcher本身 //调用watcher的addDep方法,将dep传过去。 //调用dep的addSub方法 将watcher添加到dep中 addDep: function(dep) { if (!this.depIds.hasOwnProperty(dep.id)) { dep.addSub(this); this.depIds[dep.id] = dep; } }, //添加订阅者 addSub: function(sub) { this.subs.push(sub); },

4:到这里其实页面初始化已经完成,发布订阅关系也已经绑定,剩下的时data的set勾子和input的绑定,用于处理数据变化和视图更新的同步:

m->v

set: function(newVal) { if (newVal === val) { return; } val = newVal; // 新的值是object的话,进行监听 生成dep childObj = observe(newVal); // 通知订阅者 dep.notify(); } notify: function() { //通知这个订阅器下的所有订阅者 this.subs.forEach(function(sub) { sub.update(); }); } update: function() { var value = this.get(); var oldVal = this.value; if (value !== oldVal) { this.value = value; //进入complie中执行 callback this.cb.call(this.vm, value, oldVal); } } //callback 更新了视图 textUpdater: function(node, value) { node.textContent = typeof value == 'undefined' ? '' : value; }

v -> m

model: function(node, vm, exp) { this.bind(node, vm, exp, 'model'); var me = this, val = this._getVMVal(vm, exp); //只有绑定了 v-model的input才会绑定事件 node.addEventListener('input', function(e) { var newValue = e.target.value; if (val === newValue) { return; } me._setVMVal(vm, exp, newValue); val = newValue; }); } //同样的进入set勾子里面 ...

vueMVVM实现完毕,需要注意几点:

1:数据劫持发生在init阶段,后续向data中加入的数据不会被劫持。

2:每个订阅器除了添加自身的watcher还会添加子属性的watcher

angular的双向绑定实现

angular实现双向绑定采用的脏值检查来对比数据的变更。

1:模板编译

DOM的编译是由 $compile 方法来执行的。 这个方法会遍历DOM并找到匹配 
的指令。并调用其complie方法

2:指令解析 pg:23623

ng-bind为例

var ngBindDirective = ['$compile', function($compile) { return { restrict: 'AC', compile: function ngBindCompile(templateElement) { $compile.$$addBindingClass(templateElement); return function ngBindLink(scope, element, attr) { $compile.$$addBindingInfo(element, attr.ngBind); element = element[0]; scope.$watch(attr.ngBind, function ngBindWatchAction(value) { element.textContent = value === undefined ? '' : value; }); }; } }; }];

发现其在scope.watch回调函数中来修改dom元素的文本内容。在修改了对应的scope属性值之后,触发了scope.$watch调用了ngBindWatchAction回调函数才导致页面元素文本变化的。

3:watch内部实现,生成了多个watcher对象,然后将之添加到scope.$watchers数组中。 pg:16452

$watch: function(watchExp, listener, objectEquality) { var get = $parse(watchExp); if (get.$$watchDelegate) { return get.$$watchDelegate(this, listener, objectEquality, get); } var scope = this, array = scope.$$watchers, watcher = { fn: listener, last: initWatchVal, get: get, exp: watchExp, eq: !!objectEquality }; lastDirtyWatch = null; if (!isFunction(listener)) { watcher.fn = noop; } if (!array) { array = scope.$$watchers = []; } array.unshift(watcher); return function deregisterWatch() { arrayRemove(array, watcher); lastDirtyWatch = null; }; }

scope值的改变如何触发scope.watch的执行呢?

4:在脏值检测中去执行 current.$$watchers 的遍历检测。pg:16812

当digest循环发生的时候,它会遍历当前scope及其所有子$scope上已注册
的所有watchers函数。遍历一遍所有watcher函数称为一轮脏检查。
执行完一轮脏检查,如果任何一个watcher所监听的值改变过,那么就会重新再进行一轮脏检查,直到所有的watcher函数都报告其所监听的值不再变了。

$digest: function() { var watch, value, last, fn, get, watchers, length, dirty, ttl = TTL, next, current, target = this, watchLog = [], logIdx, asyncTask; beginPhase('$digest'); // Check for changes to browser url that happened in sync before the call to $digest $browser.$$checkUrlChange(); if (this === $rootScope && applyAsyncId !== null) { // If this is the root scope, and $applyAsync has scheduled a deferred $apply(), then // cancel the scheduled $apply and flush the queue of expressions to be evaluated. $browser.defer.cancel(applyAsyncId); flushApplyAsync(); } lastDirtyWatch = null; do { // "while dirty" loop dirty = false; current = target; while (asyncQueue.length) { try { asyncTask = asyncQueue.shift(); asyncTask.scope.$eval(asyncTask.expression, asyncTask.locals); } catch (e) { $exceptionHandler(e); } lastDirtyWatch = null; } traverseScopesLoop: do { // "traverse the scopes" loop if ((watchers = current.$$watchers)) { // process our watches length = watchers.length; while (length--) { try { watch = watchers[length]; // Most common watches are on primitives, in which case we can short // circuit it with === operator, only when === fails do we use .equals if (watch) { get = watch.get; if ((value = get(current)) !== (last = watch.last) && !(watch.eq ? equals(value, last) : (typeof value === 'number' && typeof last === 'number' && isNaN(value) && isNaN(last)))) { dirty = true; lastDirtyWatch = watch; watch.last = watch.eq ? copy(value, null) : value; fn = watch.fn; fn(value, ((last === initWatchVal) ? value : last), current); if (ttl < 5) { logIdx = 4 - ttl; if (!watchLog[logIdx]) watchLog[logIdx] = []; watchLog[logIdx].push({ msg: isFunction(watch.exp) ? 'fn: ' + (watch.exp.name || watch.exp.toString()) : watch.exp, newVal: value, oldVal: last }); } } else if (watch === lastDirtyWatch) { // If the most recently dirty watcher is now clean, short circuit since the remaining watchers // have already been tested. dirty = false; break traverseScopesLoop; } } } catch (e) { $exceptionHandler(e); } } } // Insanity Warning: scope depth-first traversal // yes, this code is a bit crazy, but it works and we have tests to prove it! // this piece should be kept in sync with the traversal in $broadcast if (!(next = ((current.$$watchersCount && current.$$childHead) || (current !== target && current.$$nextSibling)))) { while (current !== target && !(next = current.$$nextSibling)) { current = current.$parent; } } } while ((current = next)); // `break traverseScopesLoop;` takes us to here if ((dirty || asyncQueue.length) && !(ttl--)) { clearPhase(); throw $rootScopeMinErr('infdig', '{0} $digest() iterations reached. Aborting!\n' + 'Watchers fired in the last 5 iterations: {1}', TTL, watchLog); } } while (dirty || asyncQueue.length); clearPhase(); while (postDigestQueue.length) { try { postDigestQueue.shift()(); } catch (e) { $exceptionHandler(e); } } } //通过$apply实现 pg:17121 $apply: function(expr) { try { beginPhase('$apply'); try { return this.$eval(expr); } finally { clearPhase(); } } catch (e) { $exceptionHandler(e); } finally { try { $rootScope.$digest(); } catch (e) { $exceptionHandler(e); throw e; } } }

我们什么时候调用$digest方法呢?

5:Angular中的digest函数:当接受view上的事件指令转发的事件时,就会切换到Angular的上下文环境,来影响这类事件,digest循环就会触发。

DOM事件,譬如用户输入文本,点击按钮等。( ng-click )

XHR响应事件 ( $http )

浏览器Location变更事件 ( $location )

Timer事件( $timeout , $interval )

执行 $digest() 或 $apply()

两种双向绑定的比较,脏值检测也不是一无是处

eg:

function TestCtrl($scope) { $scope.numOfCheckedItems = 0; var list = []; for (var i=0; i<10000; i++) { list.push({ index: i, checked: false }); } $scope.list = list; $scope.toggleChecked = function(flag) { for (var i=0; i<list.length; i++) { list[i].checked = flag; $scope.numOfCheckedItems++; } }; }

如果时基于数据劫持的数据处理,对于这种频繁变化的数据变更会很吃力,效率低下。
而脏值检测则没有这种性能问题,脏值检测会直到watcher无变化后才会合并到DOM
脏值检测也不必在初始化时必须定义所有要使用的数据。

文中用到的代码来自链接