经过两个星期的性能优化,avalon终于实现在一个页面绑定达到上万个的时候不卡顿的目标(angular的限制是2000)。现在稍作休息,总结一下avalon遇到的一些难题。
首先是如何监控的问题。所有MVVM要将VM中的属性与视图中的绑定属性关联起来大抵有如下三种方式:angular是对函数体取toString进行预编译,将里面的赋值语句,取值语句替换为set,get方法,然后通过特定方法进行脏检测触发,或手动触发;ko是对VM的属性用监控函数外包一层,全事件驱动触发;avalon是通过Object.defineProperties重写内部set,get函数,全事件驱动触发。此外还有emberjs,它是统一使用上帝set,get方法接触所有取值赋值的入口,全事件驱动触发,算是angular的改良。从用户体验来说,avalon的实现是最好的,因为它是不用改变用户习惯,emberjs次之,强制使用set,get方法,ko让函数、数组变成了函数,让用户感到非常违和,angular最差,一大堆恶心的限制,无法直接操作VM。出现这局面是因为Object.defineProperty不好兼容,它虽然是IE8支持,但那只在元素节点上存在。除非你摒弃IE8了。直接我找到VBScript,这问题才不算问题。我的优势是,我至一开始就知道有VBS这东西存在,在avalon实现之初就开始动用这东西。
ms-if的实现,说到底是生命周期的设计问题,如何销毁一个绑定及在特殊情况还让它继续存活。VM与视图的关联点在于绑定属性,绑定属性会转换为求值函数,求值函数将与它的上下文环境(比如它所在的元素节点,它原来的绑定属性的名字,值,类型,过滤器定义情况等等)组成一个对象,放到一个数组中。这就是订阅者数组。数VM中的属性发生变化时(通过内部set方法被调动时得知),就会执行这个求值函数将其他东西一起执行,从而实现视图的最小化局部刷新。问题是,我们的页面有时很大,上面拥有许多绑定属性,这意味着这些中间生成的求值函数与对象将一直放在各个订阅者数组中,占用着大量内存。如果再出现像瀑布流或或定时刷新的情况,这内存占用将越来越大,让页面运行缓慢。因此就必需考虑回收内存的情况了。avalon给出的方案时,当某一个节点将出DOM树,它自身或底下的节点的原来所有绑定属性所生成的求值函数将从订阅者数组中移除。
function notifySubscribers(accessor) { //通知依赖于这个访问器的订阅者更新自身 var list = accessor[subscribers] if (list && list.length) { var args = aslice.call(arguments, 1) for (var i = list.length, fn; fn = list[--i]; ) { var el = fn.element, remove if (el && !avalon.contains(ifSanctuary, el)) { if (typeof el.sourceIndex == "number") { //IE6-IE11 remove = el.sourceIndex === 0 } else { remove = !avalon.contains(root, el) } if (remove) { //如果它没有在DOM树 list.splice(i, 1) log("Debug: remove " + fn.name) } } if (typeof fn === "function") { fn.apply(0, args) //强制重新计算自身 } else if (fn.getter) { fn.handler.apply(fn, args) //处理监控数组的方法 } else { fn.handler(fn.evaluator.apply(0, fn.args || []), el, fn) } } } }
上面就是这一操作的实现,VM要对视图进行同步都必须经过此方法notifySubscribers。每次执行时,它都会取得求值函数上的元素节点,然后判定它是否在DOM树上。在IE6-11中,我们可以判定sourceIndex 属性是否为零得知,标准浏览器可以通过根节点是否包含当前元素得知(这个contains方法内部需要做一番兼容)。
但ms-if的出现打破这和谐局面,要考虑此绑定主动移出DOM树的情况,还要判定考虑此元素什么时候插回DOM树。在循环绑定中,元素节点在一开始是在文档碎片中动态生成的,这个更麻烦。从0.6-1.1,我一直陷入这噩梦中。之前我有的方案是采用定时器,不断轮询此节点是否插入DOM,插入了才开始对它扫描。后来又发掘出DOMNodeInserted这个事件,对一些高级一点的浏览器做一些优化。到了0.982,干脆就直接假设它们一开始都没加入DOM,添加一个类名,防止移入移出时颤动,再把当前的VM列表绑定在元素上,然后判定元素是否在DOM树(又是轮询操作)。再后来是改写循环绑定部分,在ms-each, ms-repeat等执行后,再执行一个回调,扫描当前部分,这样就可以消去轮询操作了。再再后来的改进是,确保循环生成时,元素都集中一个文档碎片中,然后整体插入DOM时,这时才进行扫描。换言之,第一次它总是在DOM树里。于是就能消去contains判定,ms-if的代码大大减少。现在的ms-if今非昔比,还加入了按需加载功能。它的子元素扫描被它的绑定属性所控制,对大页面的性能优化非常有用。
批量生成与监控数组的实现,这俩是相辅相成的。早期的监控是直接在原数组中改,因此原工厂函数非常庞大。后来直接把这些要覆盖的函数放到一个对象上,然后工厂方法里直接mix一下就行了。还简接让所有监控数组共享了这些方法,节省内存。在绑定的实现,之前是有许多分支,什么push, unshift, pop, shift, set, reroder, splice, clear一大堆,那个视图刷新函数太苦逼了。后来对数组的操作进行深入分析,发现所有操作无疑是做以下几种操作,添加元素,删除元素,改写元素对应的索引值,移动元素到某一位置,直接替换元素。于是改写监控数组的方法,根据add. del, index, move这四种操作进行组合(0.9.0),后来还加了clear,因为批量处理一个数组或一个子对象都用到此操作。这些操作里面都会通过notifySubscribers方法,将操作名与相应参数传到视图刷新函数,从而分配到不同分支上做DOM处理。这算是成功了一大步。内部其实还涉及到代理VM的生成算是处理,于是有了createItemModel的内部函数,然后出现了ms-with,于是它们改名了createWithModel, createEachModel。这两个方法的实现也不断改进,后来更名为createEachProxy, creatWithProxy,在ms-with里还使用了对象池技术(withMapper ,0.96),重用所有同名的键值对生成的代理对象。
到0.9.8,偷偷引入一个ms-repeat绑定。avalon早期的参考对象是knockout,它实现循环绑定时需要用到两个元素,一个父元素作容器,它下面的所有节点作模板,或者用一个虚拟节点(真实名字是两个一前一后的注释节点)圈定作用范围,里面的那些子点作模板。由于注释节点在IE6-8的UL,OL元素上会发生错乱,需要手动处理,avalon就没有更进。但在许多场合,总要外套一个父节点是非常难办,或做不到,于是移目于angular上。angular的ng-repeat只循环元素自身是一个非常好的方案,加之它又带来了$first, $last, $remove等好东西,于是avalon开始模仿。但这工程量与难度非常大,一直跌跌撞撞,在1.2时才基本算完工。其间要处理的问题是,如何让ms-repeat如何同时遍历数组与对象,对象的键值对的输出顺序(data-with-sorted回调的引进),批处理后的回调(data-*-rendered回调),回滚机制(rollback函数),如何判定子元素已经被渲染(需要在元素上添加一个标记,放便在scanAttr时执行一个回调)。回调是同事在做私自人项目提出的,最初没参数,现在能明确是add, del, index等操作了。生成代理VM与绑定标记后来抽象成一个shimController,实现批量插入与批量处理。对象池(更名为withProxyPool)也大大优化,它在一开始时就生成所有键值对代理VM,不再在求值函数里判定了。并且VM加了一个withProxyCount,进行优化。
最后一个也是最难一个至少也没有搞定,只在不断改良中,这就是UI绑定的设计。之前有一个绑定叫ms-ui,已经夭折。现在的ms-widget还是不够好。有时我想参考angular的那种方式,但又嫌它添加了太多莫名其妙的符号。但主要是因为我的框架对用户是非常放纵,不喜欢那种改配置的设计。不过就是放着现在的不管,它还有一个重大的缺陷,没有生命周期管理。这个在项目中已经暴露出来,需要用户自己定义一个destroy方法,手动销毁。我认为这是框架的份内事。接下来几星期,我就着手这方面的改进,希望能把这痛点解决掉。
最后总结一下:
当然对于刚接触这领域的人可以还有许多麻烦事,如不使用jQuery的情况如何摆平那一大堆兼容问题,如何写一个parser解析绑定属性的值,加载器,路由器,动画引擎等一大堆配套设施……这要你自求多福,好之为之了,但跨过这道坎,你就是另一个级别的人物了!