:avalon及通用MVVM的设计原理分析
【前端精英群】去哪儿网司徒正美:avalon及通用MVVM的设计原理分析 https://mp.weixin.qq.com/s?src=3×tamp=1640654265&ver=1&signature=YfS7PgVR0SjjLt4KwIQ297t4SDiLrrJu5C7uuMXpkCt6JrbQN6DbPqg0GEBJs7Gp6KE*0E8S0meDyhOEI2my2utjwnJY9Sp8w8PTpelaXPPvWTKvntPBw2lSvPhwbWa9RvtD8KTu5V6TPjYLljrxjA*nR7d4i29-X1Lx6TvQlnY=
【前端精英群】去哪儿网司徒正美:avalon及通用MVVM的设计原理分析
【本文系互联网技术联盟(ITA1024)原创首发,转载或节选内容前需获授权(授权后一周以后可以转载),且必须在正文前注明:本文转自互联网技术联盟(ITA1024)技术分享实录,微信公众号:ita1024k】
司徒正美
去哪儿网
开发总监
互联网技术联盟
ITA1024讲师团成员
本篇文章整理自司徒正美6月14日在『ITA1024前端技术精英群』里的分享实录:avalon及通用MVVM的设计原理分析。
正文如下
MVVM出现的必然性
前端的发展过程基本与后端一样,别人走过的路,前端也一个坑不差地走过。不同的是,前端的核心语言javascript早产了,前端还需要整合三门语言,再遇上第一次浏览器大战,IE独领风骚十年,前端延迟发展了十年。否则,我们今天谈的不是MVVM。
javasript与其他语言很不一样,是动态语言,没有类,因此想做一个标准库也没有参考,于是一直缺席。到Prototype.js时代,javascript的prototype威力才被发掘出来,那一大堆工具函数,是以后underscope,lodash的重要抄袭对象。
到jQuery时代,穷尽世人的智慧,将当时发现的最好DOM解决方案汇成一体,有了jQuery1.3(也是在那时,jQuery打败了Prototypejs, mootools,成为前端事实上的标准库)。即便现在,我们还享受着jQuery的恩泽。
jQuery极大地提高生产效率,让我们腾出手研究其他边缘设施。比如说cookie的处理,svg图形库,动画库,更好的异步机制(Deferred, Promise),日期格式,弹层,跨域通信……当这些领域的竞品越来越多,也出现独霸一方的王者时,后端才可能掺和进来了。
因为之前,前端是非常混乱,每一个领域的库都需要对浏览器兼容性很了解才能做得出来。而后端是不擅长这个。后端许多是计算机专业毕业,培训的内容是数据结构与算法,或是深受JAVA的陶冶,一套套的设计模式与UML。
前端经过打造出来的东西,于他们而言,就是一个个复用的单元。而前端世界是很少创造非常复杂的东西,像gmail这样的产品。其复用机制,也是内部库实现。
时代的招唤,带来requirejs,其实requirejs不站出来,许多一些我们现在不知道的库也在做同样的事。统一整个社区的JS文件的编写形式。他们管这做模块。每个模块都要声明自己依赖另外的JS文件,及将自己的东西提供别人优雅地调用(重要一点是不污染全局作用域)。
与requirejs一起成长的是nodejs,让更多后端加入。更多后端加入意味着更多经过培训的专业大脑加进来。许多有20年经验,把后端的那一套直接复制过来。因此应我开头的那句,后端的东西将在前端上重生。
从语言来说,JS从IE8嬗变,引入set, get关键字及后来的Object.defineProperty(其他浏览器则更早引入了__defineSetter__,__defineGetter__,firefox更拥有__noSuchMethod__,Object.prototype.watch这样神器)这是自省机制,能捕捉外界对自身的操作。
现代MVVM就是构建这些基石上。接着Promise的标准化与在浏览器的普及,这意味着前端应用已经足够复杂,大量与后端交互,每个交互的关系也非常复杂。然后是es6,es7那一堆整得JS不像JS的语言特性。
从架构上,前端模板如雨后春笋早出来,这当然是得益jQuery的事件代理机制,让这些不断替换的区域的交互功能还能正常工作。然后是路由系统的发展,underscore工具库的发展,最后集其大成者是backbone。当然这只是起点。但起码人看到希望。
前端如果沿循MVC那一套,还是能轻松架起非常复杂的应用。既然MVC能生,那么MVP,MVVM也能搬过来。其中MVVM是微软迄今研发出来的最大UI界面技术,或者说,这是一次颠覆性的UI革命。
正如上面所说的那样,前端动用三个语言才能打造一个页面。每个语言都有它的擅长之处。命令式语言擅长处理业务逻辑,描述式语言搞长绘制界面。后端也踩过这坑,于是有了JSLT,也有Windows Forms基于拖拽生成的UI库或生成器。这些东西,JSLT对应XSLT,早早完蛋,而拖拽生成技术,现在也只有EXT能活下来。
而MVVM不一样,首先是分层架构。处理不了,再加一层。其次是绑定技术。绑定技术是MVVM的核心,虽然说VM是真实操作的东西,但VM只是绑定的一个端。绑定技术是带来是自动化,与关注点的减少。我们只需关注于VM,不像MVC那样政出多头。
其他优势,自行谷歌。自从knockout第一次将MVVM带到前端,前端从小农社会进入蒸汽时代了(蒸汽时代的特征是,每样东西都是非常笨重,像angular, ember, react等都是庞然巨物,但杀伤力不是小农社会的兵器可比)。
MVVM带来了维护性与可交接性的大大提高,前端代码不用像jQuery那样每次整页推倒重来(这美日其名为重构)。
```html
{{aaa}}
```
```javascript
$(".aaa").text(json.aaa)
```
上面是MVVM,下面是jQuery,jQuery总是让你盯着你操作东西在哪里。就像一个小孩子,总在大人盯着。而一个成人,是不需要父母盯着。MVVM就是能给你这种安全感。当然人们对新事物总是存怀疑态度,加之微软名气不好,当谷歌带着angular闪亮登场时,MVVM之风才火起来,直到现在,angular还与另一个新秀react分庭抗礼。
MVVM与backbone等库的比较
MVVM的代表大概是angular, 而MVC则是backbone。backbone可谓是土生土长的前端产物,要运用它,就要jQuery, undercsore,此外还有模板,路由器都是经历好几世代发展出来的。就像新大陆上的印第安人文明,封闭孤独地发展了好久,还是石器时代。
angular是JAVA工业时代的产品,上面是普通人看不懂的parser, IoC, 管道符,factory, service等一大堆前端听不懂的东西。
当时backbone也不可谓不强大,其生态圏也像《冰与火之歌》的狼家那样,许多封候。但angular想产生与对抗的军队时,其灵活的指令很快就能复制一个出来(也不能说复制,类似于包裹,jQuery插件并不想叛变)
bootstrap, select2, selectize, lightbox等强大诸候很快就有了对应的angular版本。但是,jquery的插件千奇百怪,而angular的插件则出奇的统一。因为angular的自定义指令是很强悍的,就像苹果的改名部一样,有空时就从应用商店里抄几个热门APP。因此土著阵营占不了多少便宜,加之后来对前端的需求旺盛,后端人不断涌入,他们的品位还是与原来一样。
而像backbone, spine, batman等经典的JS MVC框架,其实都一个致命的弱点,就是小而美。因为前端是从一个混乱的局面中产生的,各种复杂的兼容性问题需要你有一些意想不到的奇怪hack来摆平,里面就需要这么else if(后来jQuery发明了hook对象,情况有所缓解)。一直这么混乱,因此大家的心中理想是如何大治,大治就意味着整齐划一。
jQuery的接口设计就是代表着大家的最高理想,就是那么贴合前端界的taste! 而小,则一直是前端的人追求,因为从小水管的时代长大的,1kb都是很宝贵。
来自后端的框架们(angular, ember, react),他们诞生于一个很好的时代,第三次浏览器大战快结束,大家都是使用正统的ecma262 v5来写代码,DOM那边也是正统的W3C API,对老式IE的兼容问题,也是我们这个墙内国度所惦念的。富二代框架不会理解穷人的痛苦!因此这些框架的兼容性并不太好,或者压底没有这概念。
并且不同于jquery, backbone这样英雄式的框架,那些是单人独力发展起来,即便拥有大量star,其基础已经定型了。而富二代框架,都打着某个大企业的旗号,拥有一个团队在维护。都是拿钱吃饭,每人写1千行,就有1万行,因此这些库都是功能齐全。
说到这里,富二代框架的另一个优势就冒出来了。小库小框架,由于作者的能力不足或什么,功能不齐备,那么对使用方而言,这个库没有这功能不重要,他们也能很快动手解决,上面的人如果放着不管,很快你就看到你的项目变成一个杂牌军。
一个页面引用上,50、60个jQuery插件也说不定,其中这里面可能有4,5个弹窗插件,6,7个表格插件……有人不解为什么EXT这么复杂的框架还有市场,缘故就在这里。对新手而言,所有框架与插件的难易程度都差不多,如果上面的人帮他准备好,还为他省掉到QQ群冰天雪地跪求XXX插件的时间。
MVVM框架是很轻易搞出一套可维护的UI库,因此,很快市面上也有很多可用的MVVM UI库。什么杂七杂八的工具库,现在有了webpack,将它们融入MVVM的工具链中,非常轻松。
MVVM的实现
MVVM框架是非常复杂的东西,不是简单可以说明白。先从模板系统说起吧。
```html
<div>{{aaa}}</div>
<ul>
{{for(var i = 0, n = arr.length; i < n;i++){}}
<li>{{arr[i].text}}</li>
{{}}}
</ul>
```
比如这个HTML,里面有许多花括号,我们称之为界定符,用于划分那里是纯HTML,那里是JS代码,然后通过正则或什么转换为一个函数。然后这个函数需要传入一个对象(数据源),用于得到一个完整的HTML。
```
var json = {aaa:111,arr:[{text:11},{text:12},{text:13}]}
```
但是这里有一个问题,每次都是将某个区域的元素都删掉,全部重建新的节点,再插回去。简单的应用看不出什么,但应用复杂一些,里面有表单,上面的光标与焦点就完蛋了。此外,如果涉及到一些经过复杂的用户才出现的组件,就很难还原现场了。
因此解决重点是如何实现最小化刷新。TX的某位大牛,在搞Nuclear试过在要修过的元素上加上一个特殊的属性。这个其实与MVVM的绑定属性很相近了。
绑定系统是MVVM的核心。angular也有相关的概念,叫指令。因为在angular中, 还存在自定义标签,注释节点,特殊类名,插值表达式等形态,它们与绑定属性的作用一样,用于关联某个VM,并将当中某个或某几个属性以特定的方式作用于目标元素或目标文本节点。
因此一个完整的指令应该有3个东西,关联的VM属性名(或者其高级形态:javascript表达式),所作用的元素或文本,要做什么操作(指令名)。ng-class就是操作类名,ng-repeat就是批量生成元素,ng-attr操作属性。其中属性名或javascript表达式会再编译成一个求值函数,而ng-attr则在框架里定义好其操作行为, 这叫视图刷新函数。
如何将这些指令抽出来,就要经过compile(avalon称之为扫描),像react,这一步是预处理的。过程很简单,当DOM树建完时,从上到下扫描DOM树,像angular可能痛苦些,包括元素的tagName, 属性,类名,文本内容都要检测。
avalon以ms-开头,就是减少扫描的对象。像angular, `aaa="ddd{{xxx}}"`,普通属性没有特殊标识,还要跑去判定属性值,其实是一个不好的设计。到ng2,需要处理的属性,都统统用[],()包起来或以*开头,就吸取了教训,还方便框架处理中间生成的变量。
这些指令都会变成一个个××绑定对象××,然后放到一个地方要与VM关联起来。具体到ng是一个VM(`$scope`)有一个`$$watchers`数组,里面放着这些对象,这些对象。只要VM的某个属性有变动,就跑一遍这些对象的求值函数与视图刷新函数。于是视图就实现最小化刷新了。
现在问题来了,你怎么知道VM某个属性变动。视图发生变动很易办,因为用户能改动视图的入口也只有那几个表单元素。浏览器提供了足够的事件类型让我们监听value, checked属性的变动,我们只要在这些事件回调为VM进行赋值操作就行。VM怎么通知V?
定时器
最懒的方式是定时器轮询,代表框架是 way.js。每次把VM深拷贝一个副本,然后100ms后比较原对象,发明不同刷新视图,并再深拷贝新副本。
函数包裹
knockout使用的技巧,要你定义VM时,原来拍到页面上的属性都转换成一个函数。然后通过传参与否就知道你是赋值还是取值。函数里面有一个数据会被改来改去!(赋值还是取值涉及到一个重要的内容,后面讲)。
```
var myModel = {//普通数据
personName: 'Bob',
personAge: 123
};
```
```
var myViewModel = {//VM
personName: ko.observable('Bob'),
personAge: ko.observable(123)
};
```
正常人都觉得angular很怪异,你会注意到$scope对象都是放到一个方法体内,此外还有注入依赖什么的,是用数组形式。但数组也好,函数也好,我们取其toString(),是能得到内容的,而对象则是`"[objectObject]"`。只要得到内容,就能重新编译成另一个东西,原来`$scope.a = 1`就会变成, `$scope.set("a",1)`效果与knockout一样。
属性劫持
或者说等号重载。实现的关键方法是Object.defineProperty或`__defineSetter__`,`__defineGetter__`.avalon开创性地使用VBScript解决对IE6的兼容。这是一种自省机制,有了它就不需要angular的脏检测。
其实现方案是,当目标对象的属性被赋值时,就尝试找到对应的订阅者数组(这是一个密封舱机制,一个属性对应一个数组,里面是其对应的绑定对象,而angular是一个VM对应一个数组,这会带来性能问题,其次angular的更新是不即时的,是异步的,认为这样有效缓解”震荡问题”——A属性变化,触发B属性变化,B变化…然后不经意触发A变化,会形成死链,当然angular是有一个叫TTL的变量 阻止一直循环下去)。
但一个属性与其对应数组怎么关联就有复杂。有两种方式,1 静态分析,一个强大的parser,将指令中的变量抽取出来(下面会说),2 动态依赖收集,这个在getter里做,也就是取值时做,这个knockout, avalon1.×, vue都是这么干。
上帝setter,getter
twitter的Ractive.js与facebook 的React.js就是用这个。很明显,React是师承Ractive.js。当时twitter吹了一个名词叫反应式编程,吹得天花乱坠,口躁舌干,最后失败了。还是要公司足够大才行。这东西很好理解,就是在VM中定义一个通用的set, get方法。
```javascript
vm.set('aaa.bbb',1111)
```
于是有了路径变动(子级对象的属性变化)的概念,后来polymer实现了一个很漂亮的状态机,来监听路径变动。这个被vue抄去了,于是一帮不明真相的群众就是啧啧称奇!React则是使用setState,React许多东西是不够智能,可能它不想使用这么多魔法,或者后端过来的人压底不了解这些JS魔法。
这种朴素的设计,意味着紧接着需要对新旧对象进行diff,为了加快比较效率,于是facebook又搞出immutable object,于是又一帮人来捧臭脚。这是一种为了解决问题而又发明的新问题。连抄袭小王子vue都没有抄它这个。
Proxy
这是es6 的新东西,完全不是一个同次元的动物,觉得是来自ruby星球上的。之前,谷歌还捎带了一个私货叫Object.observe,一大堆人来捧臭脚。但其他浏览器不理帐,一如其他浏览器不想实现IE 的attachEvent, detachEvent那一套私有实现一样。每次社区兴起什么新东西,大家就开始讨论“什么时候浏览器将它们标准化呢?!”这样蠢问题。
浏览器只会实现一些低level 的API,那些高级封装的API不会出现在这列表上,要不每个元素或window上就太多方法与属性了。这也是后来虚拟DOM出现 的原因之一。说回Proxy,这是一个逆天的东西,不像Object.defineProperty,只是对=号敏感。什么delete, for in,in, new,还是方法调用都能感应到!
这是一个非常顶级的自省机制,要求浏览器对普通对象内部添加大量的钩子。JS父所在的firefox很早就搞出这东西,那些还是规范1,后来标准化后,接口有点不一样。chrome也是规范1,规范2都实现过,不过中途断线了几个版本,也是够坑的。
因此不要相信浏览器,它们不是铁板一块,并且对于它们的API也可能朝令夕改。可能那些人没有什么契约概念,总之就是坑,你不得多写个if else来做特性检测!avalon2已经使用Proxy来做VM。
Proxy的相关资料:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
编译系统
其实写一个普通的前端模板都需要正则处理一下,当复杂如MVVM时,一个强大的parser是必须的。模板,或者叫做VM的作用区域,现在有两种流行的方式。
一是像react,reactive, regular(网易出品)那样,将它们单独抽取出来,放到script容器或什么位置上。
二是将它直接拍在页面上,与普通元素混在一起,使用ms-*,v-*, ng-*标识它们要特殊处理,然后用ms-controller, ng-controller圈定VM的作用域(vue是用ID)。各有各个好处。
独单拎出来,不用受浏览器的气,因为每个浏览器对元素的解析不太一样,比如旧式IE会将标签名大写化,布尔属性大写并没有属性值,去掉空白,有些属性没有双引号。有的浏览器对SVG元素添加很长的namespace。凡此种种,你要操碎了心。
其次可以直接用后端那一套静态模板的东西,block, macros什么的。后端模板发展很久了,搞鼓出大量神奇的东西。直接拍在页面上,是方便在切图人员的页面上修改。功能越强大,你的parser就要越强大。
后一种方式,还要符合HTML parser的一些潜规则,比如说option里面只能有文本,需要将`<`,`>`去掉。xmp,script, style, noscript里面则是一个整个文本节点。textarea的innerHTML需要取出来变成value,里面清空。table与tr中要加上tbody,像这样:
此外,什么元素下面只能有什么元素不能有什么元素,html规范是写得清清楚楚,react源码里面有一个模块([validateDOMNesting](https://github.com/facebook/react/blob/master/src/renderers/dom/client/validateDOMNesting.js))专门做这样的检测。
浏览器还能更智能帮你闭合一些元素(p什么)。一些微型的html parser就是玩具,只能算是xml parser。它们也不敢碰IE6-8这样的outerHTML,属性值可能有双引号括起,也可能用单引号括起,或干脆就没有。react的JSX其实很好写,以左花括号开始,然后深度加1,碰到另一个右花括号,深度减1,归零,左右界定符已经确定了,抽成JS逻辑部分。
接着说JS的变量抽取问题,这是将指令的表达式部分转换为求值函数的重要一步。就是依赖收集一个方法。流程大致如下:
`ms-attr="aaa+bbb+2"`得到`aaa+bbb+2`, 正则处理表达式。首先它不在注释里面,不在字符串里面,前面存在`([ { + - ,; `这些操作符,我们通过正则就可以将它们干掉,将它们替换为逗号。此外,我们只需要关注变量最前面的部分,如果后面跟着 . 号,这可能是方法或属性,这个也可以用正则将它替换为逗号。
经过这些里面,原来一段字符串,就剩下几个单词与逗号。这些单词可能存在`if, for, in, while`这个关键字,我们可以写一个hash,包含所有的关键字与保留字,这样也把它干掉。当然这里有一个难题,是正则字面量。正则字面量里面怎么写也可以。不过放心,将正则的变量也parser出来,匹配多了不会影响结果的。
```javascript
var vars = ['aaa','bbb']
```
```
var aaa = vm.aaa
var bbb = vm.bbb
```
后面加上原来的表达式
```
//body
var aaa = vm.aaa
var bbb = vm.bbb
return aaa+ bbb+ 2
```
最后放到Function构造函数中
```
var getter = Function ('vm',body)
```
上面正则的疑惑,比如说 `ms-if="/test/.test(aaa)"`
得到所有变量
```
var vars = ['test','aaa']
```
然后
```
var test = vm.test
var vm = vm.aaa
return /test/.test(aaa)
```
因此不会影响结果。
像react native加了这么多语法糖,我觉得是挺危险的。因为你无法保证几年后,那些信誓旦旦的语法是否还在不在。这东西说没就没了。你现在用的babel版本与插件要锁死版本,全部保存起来。说不定有一天从npm上也找不到,你就永远编译不了它。
因此我认为不要加这么多语法糖,加了也是框架内部处理。第三方违约的事见怪不怪。
复杂墙与性能墙
jQuery让DOM操作变得轻松愉快,因此人们可以搞出比以前复杂N倍的东西。MVVM将DOM隐蔽在VM的赋值取值之中(也是传说中的“操作数据即操作DOM”),人们更是毫无顾忌地搞更复杂的东西。就像无水泥前,人们盖草屋,有了水泥,人们建起了万神殿与圣家族大教堂,现在是在工业基础上建摩天大厦。每隔18个月,前端难度难一倍不是没有道理。
有这几个因素让大家信心爆棚,Nodejs风格的模块定义的流行,webpack,rollup的强大构建工程能力,浏览器的升级与API趋同。最重要的是MVVM摒蔽了DOM操作,就像一些护肤产品广告词一样,操作DOM如丝般细滑!以前可是披荆斩棘般的英雄诗歌,一调就调上半天。
最后是叠积木般开发,这个吹了好久了,从YUI到EXT到所有无名的UI库。现在官方引衔的Web Components,看起来还算不错,虽然当中的Shadow DOM就半死不活。那些附着于HTML的指令其实与这个规范相性很合。当然React那个也不错。现在这两种组件模式,是所有MVVM框架的抄袭对象。
复杂墙就是由组件来搞定。组件就是一堆指令的集合,及加上生命周期钩子。
此外复杂度提升,也带来性能问题。angular就有2000指令之轭。用户的每次操作是会生成大量中间变量与临时函数,即便你加了多少缓存,还是很难避免。于是有了各种办法,异步渲染(将多次VM的操作,合成一次UI渲染),密封舱机制(精准地得知那些绑定对象是发生了变化,不做无效比较),大模板函数(将整个视图编译成一个大函数,减少进入大量小函数时,重复创建作用域对象造成的消耗),最后当然还有如雷震耳的虚拟DOM。虚拟DOM是facebook发明的,是目前最好最简捷的对付性能墙的方法。
组件系统
现在两个主流组件系统的都是基于自定义标签,而不是JS类。虽然后面可能有Picker, Calendar这样的支撑类。
主要思想大概如下,通过一种特殊的tagName标识这是一个自定义标签,然后能找到后面的支撑类,然后调用其render方法,将上面传递下来VM与自带数据(props)一结合,得到一个标准的HTML元素。组件里面许多行为,是组件内部诞生的VM与外部VM共同操作,行为的实现是指令摆平。
因此我说,组件是指令的集合体。但组件的资源可能是异步的,因此组件VM可能延迟生成,并且如何拿到这个组件VM也是一个难点。你说是放到生成的HTML元素上,还是放到某个回调好。为了CG,共同的做法是放到回调里,于是有了生命周期钩子,如createdCallback, attachedCallback, detachedCallback,attributeChangedCallback。
这些东西就是从firefox的x-tag 中抄过来的。avalon2还有另一个好办法,就是操作配置对象能就操作组件(配置对象也是VM中的某个子对象)
从实现成本来说,react的成本是非常高,与JSX绑定在一起,但那个纯JS方式是不可能有人用的!
那么另一条路就是写在HTML文档的自定义标签,在旧式IE下我们可以用带命名空间的标签(一般会被识别为VML)。到IE9或其他浏览器,则是一个HTMLUnknownElement。
具体如何识别HTMLUnknownElement 可以看一下我2012年的文章
http://www.cnblogs.com/rubylouvre/archive/2012/05/02/2478461.html
到框架层面,这个识别会更简单,像React,只要是大写开头就是组件标签。avalon1.5,是带ms:开头的标签,avalon2是带ms-开头的标签或带ms-widget属性的标签。一些框架则是用is属性来做标识。
再说一下几个钩子,createdCallback,attachedCallback,attributeChangedCallback是很好实现,但移除就有点麻烦,因为许多不可控因素在里面。特别是与jQuery混用时。而浏览器提供了大量移除节点的API,搞不好就内存泄露。因此下面有个小节专门说这个。
虚拟DOM
虚拟DOM的核心思想是用轻量对象代替重型对象,承担大量原本由真实DOM参与的计算。
```
超轻量 Object.create(nulll)
轻量一般的对象 {}
重量带有访问器属性的对象, avalon或vue的VM对象
超重量各种节点或window对象
```
这其实分层架构的应用,解决不了性能问题,于是专门引进一个层来专门处理它。虚拟DOM层是一个缓冲层。经典的MVVM框架其实有个问题,为了实现最小化刷新,所有绑定对象会保留原位置上元素节点或文本节点,因此需要引入额外的机制,将无效的绑定对象进行“清洗”,要不有节点在上面会内存泄漏。虚拟DOM是不保有那些要操作节点在某个位置上。
虚拟DOM是一个树状结构,真实DOM是怎么排列,有什么属性(排除掉那些看不到方法与固有属性),虚拟DOM就是怎么排列,有什么属性。
每次VM变动,一个全新的虚拟DOM树就立即生成出来,然后新旧虚拟DOM树比较,得到patch(不同框架会做不同的优化),最后应用到真实DOM上。每次更新都是从上到下做有效的更新。而优化手段,可能是修改更新起点。比如说react,某个组件变化,它就只从其容器元素开始更新。这就要求有一种机制,识别这个属性是组件自带的属性,还是外面的VM。
为了快速将虚拟DOM树生成出来,那个编译函数自开始就是返回一个虚拟DOM,而不是一个HTML字符串。
比如这样:
它的编译函数就是这样:
现在虚拟DOM是没有统一的标准,大抵是一个普通对象,都有type(或叫tagName), props, children三个属性。为了提高比较速度,可以加上skipProps(忽略比较属性),skipContent(忽略比较子节点), outerHTML(加快比较两个组件或元素)。
然后是diff,diff属性变动或文本变动最简单。但比较子元素的排列顺序则难多了,于是react让我们为循环生成的子元素加上一个不重复的key属性,angular则使用trace by属性或函数。这是一种优化方案。但通用方案是使用最短编辑距离算法,得到所有元素的新旧位置及操作形态,
具体可以看knockout
https://github.com/knockout/knockout/tree/master/src/binding/editDetection
avalon2则什么也没用,使用另一种hashcode算法搞定的。
最后是patch了,因为真正让页面变化还是DOM操作。经过这么多胶水,DOM操作还是要做的。得益了jQuery发现那么多技巧,我们可以将相关的函数偷师到我们的框架上。
内存泄露处理与回收利用
因为MVVM内部太复杂了,如果你的parser是第三方的,组件内用到DOM操作,就有许多不可控因素了。
上面说到一个方案是,不要保留DOM节点到你的框架内,否则每次你都要做清理操作。此外还有求值函数的编译生成,Function是最好的,with有风险 ,严格模式下整个框架会挂掉。eval可能将变量变成全局的。最后是事件回调问题,绑定事件是消耗性能,移除节点不解绑事件则会内存泄漏,因此avalon2与react 使用了事件代理,于是没有这么多破事了。
上面我还说到,每个指令的表达式部分都会变成一个函数,这些函数真的每次都new吗?
其实可以缓存起来。于是就有了带数量限制的缓存体的需求。纵观这么多MVVM框架,都选择了LUR。在Sizzle里面有一个简单createCache也不错。
而每一个组件,其实也可以缓存起来。我说不单指组件的VM部分,还有对应 的DOM。比如说SPA,每一页都不一样,每个页面都有组件,当我跳到另一页做一些操作,再退回来,如果DOM缓存了,就直接插回去,不用createElement。
事件系统优化
如果翻开react的源码,你会发现至少有三分之一代码都耗在这上面。react企图使用纯JS重新实现DOM事件系统。比如说mouseenter,mouseleave,它就是比较虚拟节点的data-reactid,通过来共同祖先解决。 由于它完全与DOM绝缘,因此下面的介质从DOM换成oc,java等语言对象,也是能安稳运行起来。
不过你会发现, react的事件系统 是构建data-reactid这个UUID上。但那个事件系统不是呢!从Dean Edward大神的addEvents库起,强大的事件库都在某种UUID上构建的。UUID算法有很多种,但我们其实不需要它们。
avalon2是以函数体作为UUID!!!
```
ms-click="alert(aaa+2)"
```
将函数体取出来,去掉空白,将特殊字符取charCode,于是得到`alert40aaa43241`.然后以UUID与编译好的方法存进缓存系统。
目标元素设置一个属性,记录事件类型与UUID。将此事件绑在根节点上。当用户点击页面任何位置,就从事件源对象取这个属性,再抽取事件类型与UUID,再得到回调,传入VM与事件对象执行!
这个流程与 jQuery有点像,但简单许多。有兴趣可以看avalon2的源码
https://github.com/RubyLouvre/avalon/blob/master/src/dom/event/modern.js
diff优化
即便你不用虚拟DOM,你还是要diff,不是在真实DOM上,就是在VM上。显然在虚拟DOM上划算。既然是两棵树,diff起来有点耗时,网上也提出许多优化方案了。但虚拟DOM加一些额外的属性就能加快比较速度。上面就提到skipProp, skipContent, outerHTML。其实它们还是取出两个虚拟DOM的相关属性检测一下。还有更快的方式。
```
<div id="aaa">
<div id="bbb"ms-attr="{title:@title}"><strong>111</strong><span>222</span></div>
</div>
```
比如上面的结果,#aaa肯定要转换为虚拟DOM,因为下级有指令, #bbb也要转换,因为它有绑定属性。但strong, span可以不用转换。#bbb的innerHTML作为一个属性存于#bbb的虚拟DOM上。于是少了许多就diff, patch了。
有人说,那干脆连上面的#aaa也不转了。这样做可以,但需要你保留真实DOM到对应的绑定对象或虚拟DOM上。
我想到就这么多,有更好的技巧可告诉我。
avaon2的相关技巧
下面是安利时间。
avalon2是虚拟DOM化的avalon。avalon1由于迟迟没有实现组件,因此失去不少市场。avalon2在这上面做了大量改进。
首先avalon2的指令与ko或ng1是很相近的。不过它不使用动态依赖收集(ko,vue还在用),是使用单纯的静态词法分析。动态依赖收集太耗性能了。词法分析与上面说得差不多,不过更简单,因此现在要求在每个VM高变量前带上`@`或`##`,那样我直接正则替换@aaa为vm.aaa。因此在parser阶段,速度就像飞一样。每个指令除了ms-for,其他都是真正JS数组,对象或变量,因此转换为求值函数时少了许多破事。
创建节点上,不要用innerHTML。innerHTML其实很破的,不能创建正确的script节点,在IE下许多自定义元素创建不了,还有readonly 的问题。 react 还发现一种叫[DOMLazyTree](https://github.com/facebook/react/blob/master/src/renderers/dom/client/utils/DOMLazyTree.js)的技术,这个日后会引进来。
更新视图上,没有使用异步。因为异步不是一种友好的编程方式。react的视图更新就没有异步,它是将更新放到一个列队中,当在更新过程中又触发一个视图更新,它会此更新放到后面,执行完再执行这个。当然,这种情况是react不太愿意见到的,因此建议单向流动,不要用双绑。但即便遇上,react的更新机制还是能hold住。
组件系统上,主要onDispose钩子,如何监听一个元素是否被移出DOM,内置三种方式,MutationEvent,元素原型链属性或方法重写,及轮询。此外在onDispose对组件元素进行回收利用。
精确或近乎精确得知元素何时被移除
MutationEvent
```
function byMutationEvent(dom) {
dom.addEventListener("DOMNodeRemovedFromDocument", function (){
fireDisposeHookDelay(dom)
})
}
```
重写所有可能移除元素的原生方法或属性(appendChild, insertBefore, replaceChild, removeChild, innerHTML)。 IE9+有效!
```
//https://www.web-tinker.com/article/20618.html?utm_source=tuicool&utm_medium=referral
//IE6-8虽然暴露了Element.prototype,但无法重写已有的DOM API
varp = Node.prototype
function rewite(name, fn) {
var cb = p[name]
p[name] = function (a, b) {
return fn.call(this, cb, a, b)
}
}
rewite('removeChild', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1) {
fireDisposeHookDelay(a)
}
return a
})
rewite('replaceChild', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1) {
fireDisposeHookDelay(a)
}
return a
})
//将元素节点放到一个文档碎片上
rewite('appendChild', function (fn, a) {
fn.call(this, a)
if (a.nodeType === 1 && this.nodeType === 11) {
fireDisposeHookDelay(a)
}
return a
})
rewite('insertBefore', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1 && this.nodeType === 11) {
fireDisposeHookDelay(a)
}
return a
})
//访问器属性需要用getOwnPropertyDescriptor处理
varep = Element.prototype, oldSetter
function newSetter(html) {
var all = avalon.slice(this.getElementsByTagName('*'))
oldSetter.call(this, html)
fireDisposedComponents(all)
}
if(!Object.getOwnPropertyDescriptor) {
oldSetter = ep.__lookupSetter__('innerHTML')
ep.__defineSetter__('innerHTML', newSetter)
}else {
var obj = Object.getOwnPropertyDescriptor(ep, 'innerHTML')
oldSetter = obj.set
obj.set = newSetter
Object.defineProperty(ep, 'innerHTML', obj)
}
```
生成虚拟DOM上,也是上面提到过的大模板函数方式,直接产出虚拟DOM树。
当然还有许多细节,需要涉及框架更多东西,很能单挑出来,就不说了。有兴趣的可以看。没兴趣就罢了。编程的乐趣就看别人的源码,重造更好的轮子。只要了解许多技巧才能出奇制胜。国内也有许多人模仿vue,react造了一些MVVM框架,但没什么用。
因为思想深度不够,模仿得像画虎类猫,邯郸学步。必须深刻理解,然后出奇制胜才行。什么微创新,没什么用,程序员是很高智的群体,不会放着正宗的不用,去用山寨货。
我的分享就到这里,谢谢大家。
【前端精英群】去哪儿网司徒正美:avalon及通用MVVM的设计原理分析
【本文系互联网技术联盟(ITA1024)原创首发,转载或节选内容前需获授权(授权后一周以后可以转载),且必须在正文前注明:本文转自互联网技术联盟(ITA1024)技术分享实录,微信公众号:ita1024k】
司徒正美
去哪儿网
开发总监
互联网技术联盟
ITA1024讲师团成员
本篇文章整理自司徒正美6月14日在『ITA1024前端技术精英群』里的分享实录:avalon及通用MVVM的设计原理分析。
正文如下
MVVM出现的必然性
前端的发展过程基本与后端一样,别人走过的路,前端也一个坑不差地走过。不同的是,前端的核心语言javascript早产了,前端还需要整合三门语言,再遇上第一次浏览器大战,IE独领风骚十年,前端延迟发展了十年。否则,我们今天谈的不是MVVM。
javasript与其他语言很不一样,是动态语言,没有类,因此想做一个标准库也没有参考,于是一直缺席。到Prototype.js时代,javascript的prototype威力才被发掘出来,那一大堆工具函数,是以后underscope,lodash的重要抄袭对象。
到jQuery时代,穷尽世人的智慧,将当时发现的最好DOM解决方案汇成一体,有了jQuery1.3(也是在那时,jQuery打败了Prototypejs, mootools,成为前端事实上的标准库)。即便现在,我们还享受着jQuery的恩泽。
jQuery极大地提高生产效率,让我们腾出手研究其他边缘设施。比如说cookie的处理,svg图形库,动画库,更好的异步机制(Deferred, Promise),日期格式,弹层,跨域通信……当这些领域的竞品越来越多,也出现独霸一方的王者时,后端才可能掺和进来了。
因为之前,前端是非常混乱,每一个领域的库都需要对浏览器兼容性很了解才能做得出来。而后端是不擅长这个。后端许多是计算机专业毕业,培训的内容是数据结构与算法,或是深受JAVA的陶冶,一套套的设计模式与UML。
前端经过打造出来的东西,于他们而言,就是一个个复用的单元。而前端世界是很少创造非常复杂的东西,像gmail这样的产品。其复用机制,也是内部库实现。
时代的招唤,带来requirejs,其实requirejs不站出来,许多一些我们现在不知道的库也在做同样的事。统一整个社区的JS文件的编写形式。他们管这做模块。每个模块都要声明自己依赖另外的JS文件,及将自己的东西提供别人优雅地调用(重要一点是不污染全局作用域)。
与requirejs一起成长的是nodejs,让更多后端加入。更多后端加入意味着更多经过培训的专业大脑加进来。许多有20年经验,把后端的那一套直接复制过来。因此应我开头的那句,后端的东西将在前端上重生。
从语言来说,JS从IE8嬗变,引入set, get关键字及后来的Object.defineProperty(其他浏览器则更早引入了__defineSetter__,__defineGetter__,firefox更拥有__noSuchMethod__,Object.prototype.watch这样神器)这是自省机制,能捕捉外界对自身的操作。
现代MVVM就是构建这些基石上。接着Promise的标准化与在浏览器的普及,这意味着前端应用已经足够复杂,大量与后端交互,每个交互的关系也非常复杂。然后是es6,es7那一堆整得JS不像JS的语言特性。
从架构上,前端模板如雨后春笋早出来,这当然是得益jQuery的事件代理机制,让这些不断替换的区域的交互功能还能正常工作。然后是路由系统的发展,underscore工具库的发展,最后集其大成者是backbone。当然这只是起点。但起码人看到希望。
前端如果沿循MVC那一套,还是能轻松架起非常复杂的应用。既然MVC能生,那么MVP,MVVM也能搬过来。其中MVVM是微软迄今研发出来的最大UI界面技术,或者说,这是一次颠覆性的UI革命。
正如上面所说的那样,前端动用三个语言才能打造一个页面。每个语言都有它的擅长之处。命令式语言擅长处理业务逻辑,描述式语言搞长绘制界面。后端也踩过这坑,于是有了JSLT,也有Windows Forms基于拖拽生成的UI库或生成器。这些东西,JSLT对应XSLT,早早完蛋,而拖拽生成技术,现在也只有EXT能活下来。
而MVVM不一样,首先是分层架构。处理不了,再加一层。其次是绑定技术。绑定技术是MVVM的核心,虽然说VM是真实操作的东西,但VM只是绑定的一个端。绑定技术是带来是自动化,与关注点的减少。我们只需关注于VM,不像MVC那样政出多头。
其他优势,自行谷歌。自从knockout第一次将MVVM带到前端,前端从小农社会进入蒸汽时代了(蒸汽时代的特征是,每样东西都是非常笨重,像angular, ember, react等都是庞然巨物,但杀伤力不是小农社会的兵器可比)。
MVVM带来了维护性与可交接性的大大提高,前端代码不用像jQuery那样每次整页推倒重来(这美日其名为重构)。
```html
{{aaa}}
```
```javascript
$(".aaa").text(json.aaa)
```
上面是MVVM,下面是jQuery,jQuery总是让你盯着你操作东西在哪里。就像一个小孩子,总在大人盯着。而一个成人,是不需要父母盯着。MVVM就是能给你这种安全感。当然人们对新事物总是存怀疑态度,加之微软名气不好,当谷歌带着angular闪亮登场时,MVVM之风才火起来,直到现在,angular还与另一个新秀react分庭抗礼。
MVVM与backbone等库的比较
MVVM的代表大概是angular, 而MVC则是backbone。backbone可谓是土生土长的前端产物,要运用它,就要jQuery, undercsore,此外还有模板,路由器都是经历好几世代发展出来的。就像新大陆上的印第安人文明,封闭孤独地发展了好久,还是石器时代。
angular是JAVA工业时代的产品,上面是普通人看不懂的parser, IoC, 管道符,factory, service等一大堆前端听不懂的东西。
当时backbone也不可谓不强大,其生态圏也像《冰与火之歌》的狼家那样,许多封候。但angular想产生与对抗的军队时,其灵活的指令很快就能复制一个出来(也不能说复制,类似于包裹,jQuery插件并不想叛变)
bootstrap, select2, selectize, lightbox等强大诸候很快就有了对应的angular版本。但是,jquery的插件千奇百怪,而angular的插件则出奇的统一。因为angular的自定义指令是很强悍的,就像苹果的改名部一样,有空时就从应用商店里抄几个热门APP。因此土著阵营占不了多少便宜,加之后来对前端的需求旺盛,后端人不断涌入,他们的品位还是与原来一样。
而像backbone, spine, batman等经典的JS MVC框架,其实都一个致命的弱点,就是小而美。因为前端是从一个混乱的局面中产生的,各种复杂的兼容性问题需要你有一些意想不到的奇怪hack来摆平,里面就需要这么else if(后来jQuery发明了hook对象,情况有所缓解)。一直这么混乱,因此大家的心中理想是如何大治,大治就意味着整齐划一。
jQuery的接口设计就是代表着大家的最高理想,就是那么贴合前端界的taste! 而小,则一直是前端的人追求,因为从小水管的时代长大的,1kb都是很宝贵。
来自后端的框架们(angular, ember, react),他们诞生于一个很好的时代,第三次浏览器大战快结束,大家都是使用正统的ecma262 v5来写代码,DOM那边也是正统的W3C API,对老式IE的兼容问题,也是我们这个墙内国度所惦念的。富二代框架不会理解穷人的痛苦!因此这些框架的兼容性并不太好,或者压底没有这概念。
并且不同于jquery, backbone这样英雄式的框架,那些是单人独力发展起来,即便拥有大量star,其基础已经定型了。而富二代框架,都打着某个大企业的旗号,拥有一个团队在维护。都是拿钱吃饭,每人写1千行,就有1万行,因此这些库都是功能齐全。
说到这里,富二代框架的另一个优势就冒出来了。小库小框架,由于作者的能力不足或什么,功能不齐备,那么对使用方而言,这个库没有这功能不重要,他们也能很快动手解决,上面的人如果放着不管,很快你就看到你的项目变成一个杂牌军。
一个页面引用上,50、60个jQuery插件也说不定,其中这里面可能有4,5个弹窗插件,6,7个表格插件……有人不解为什么EXT这么复杂的框架还有市场,缘故就在这里。对新手而言,所有框架与插件的难易程度都差不多,如果上面的人帮他准备好,还为他省掉到QQ群冰天雪地跪求XXX插件的时间。
MVVM框架是很轻易搞出一套可维护的UI库,因此,很快市面上也有很多可用的MVVM UI库。什么杂七杂八的工具库,现在有了webpack,将它们融入MVVM的工具链中,非常轻松。
MVVM的实现
MVVM框架是非常复杂的东西,不是简单可以说明白。先从模板系统说起吧。
```html
<div>{{aaa}}</div>
<ul>
{{for(var i = 0, n = arr.length; i < n;i++){}}
<li>{{arr[i].text}}</li>
{{}}}
</ul>
```
比如这个HTML,里面有许多花括号,我们称之为界定符,用于划分那里是纯HTML,那里是JS代码,然后通过正则或什么转换为一个函数。然后这个函数需要传入一个对象(数据源),用于得到一个完整的HTML。
```
var json = {aaa:111,arr:[{text:11},{text:12},{text:13}]}
```
但是这里有一个问题,每次都是将某个区域的元素都删掉,全部重建新的节点,再插回去。简单的应用看不出什么,但应用复杂一些,里面有表单,上面的光标与焦点就完蛋了。此外,如果涉及到一些经过复杂的用户才出现的组件,就很难还原现场了。
因此解决重点是如何实现最小化刷新。TX的某位大牛,在搞Nuclear试过在要修过的元素上加上一个特殊的属性。这个其实与MVVM的绑定属性很相近了。
绑定系统是MVVM的核心。angular也有相关的概念,叫指令。因为在angular中, 还存在自定义标签,注释节点,特殊类名,插值表达式等形态,它们与绑定属性的作用一样,用于关联某个VM,并将当中某个或某几个属性以特定的方式作用于目标元素或目标文本节点。
因此一个完整的指令应该有3个东西,关联的VM属性名(或者其高级形态:javascript表达式),所作用的元素或文本,要做什么操作(指令名)。ng-class就是操作类名,ng-repeat就是批量生成元素,ng-attr操作属性。其中属性名或javascript表达式会再编译成一个求值函数,而ng-attr则在框架里定义好其操作行为, 这叫视图刷新函数。
如何将这些指令抽出来,就要经过compile(avalon称之为扫描),像react,这一步是预处理的。过程很简单,当DOM树建完时,从上到下扫描DOM树,像angular可能痛苦些,包括元素的tagName, 属性,类名,文本内容都要检测。
avalon以ms-开头,就是减少扫描的对象。像angular, `aaa="ddd{{xxx}}"`,普通属性没有特殊标识,还要跑去判定属性值,其实是一个不好的设计。到ng2,需要处理的属性,都统统用[],()包起来或以*开头,就吸取了教训,还方便框架处理中间生成的变量。
这些指令都会变成一个个××绑定对象××,然后放到一个地方要与VM关联起来。具体到ng是一个VM(`$scope`)有一个`$$watchers`数组,里面放着这些对象,这些对象。只要VM的某个属性有变动,就跑一遍这些对象的求值函数与视图刷新函数。于是视图就实现最小化刷新了。
现在问题来了,你怎么知道VM某个属性变动。视图发生变动很易办,因为用户能改动视图的入口也只有那几个表单元素。浏览器提供了足够的事件类型让我们监听value, checked属性的变动,我们只要在这些事件回调为VM进行赋值操作就行。VM怎么通知V?
定时器
最懒的方式是定时器轮询,代表框架是 way.js。每次把VM深拷贝一个副本,然后100ms后比较原对象,发明不同刷新视图,并再深拷贝新副本。
函数包裹
knockout使用的技巧,要你定义VM时,原来拍到页面上的属性都转换成一个函数。然后通过传参与否就知道你是赋值还是取值。函数里面有一个数据会被改来改去!(赋值还是取值涉及到一个重要的内容,后面讲)。
```
var myModel = {//普通数据
personName: 'Bob',
personAge: 123
};
```
```
var myViewModel = {//VM
personName: ko.observable('Bob'),
personAge: ko.observable(123)
};
```
正常人都觉得angular很怪异,你会注意到$scope对象都是放到一个方法体内,此外还有注入依赖什么的,是用数组形式。但数组也好,函数也好,我们取其toString(),是能得到内容的,而对象则是`"[objectObject]"`。只要得到内容,就能重新编译成另一个东西,原来`$scope.a = 1`就会变成, `$scope.set("a",1)`效果与knockout一样。
属性劫持
或者说等号重载。实现的关键方法是Object.defineProperty或`__defineSetter__`,`__defineGetter__`.avalon开创性地使用VBScript解决对IE6的兼容。这是一种自省机制,有了它就不需要angular的脏检测。
其实现方案是,当目标对象的属性被赋值时,就尝试找到对应的订阅者数组(这是一个密封舱机制,一个属性对应一个数组,里面是其对应的绑定对象,而angular是一个VM对应一个数组,这会带来性能问题,其次angular的更新是不即时的,是异步的,认为这样有效缓解”震荡问题”——A属性变化,触发B属性变化,B变化…然后不经意触发A变化,会形成死链,当然angular是有一个叫TTL的变量 阻止一直循环下去)。
但一个属性与其对应数组怎么关联就有复杂。有两种方式,1 静态分析,一个强大的parser,将指令中的变量抽取出来(下面会说),2 动态依赖收集,这个在getter里做,也就是取值时做,这个knockout, avalon1.×, vue都是这么干。
上帝setter,getter
twitter的Ractive.js与facebook 的React.js就是用这个。很明显,React是师承Ractive.js。当时twitter吹了一个名词叫反应式编程,吹得天花乱坠,口躁舌干,最后失败了。还是要公司足够大才行。这东西很好理解,就是在VM中定义一个通用的set, get方法。
```javascript
vm.set('aaa.bbb',1111)
```
于是有了路径变动(子级对象的属性变化)的概念,后来polymer实现了一个很漂亮的状态机,来监听路径变动。这个被vue抄去了,于是一帮不明真相的群众就是啧啧称奇!React则是使用setState,React许多东西是不够智能,可能它不想使用这么多魔法,或者后端过来的人压底不了解这些JS魔法。
这种朴素的设计,意味着紧接着需要对新旧对象进行diff,为了加快比较效率,于是facebook又搞出immutable object,于是又一帮人来捧臭脚。这是一种为了解决问题而又发明的新问题。连抄袭小王子vue都没有抄它这个。
Proxy
这是es6 的新东西,完全不是一个同次元的动物,觉得是来自ruby星球上的。之前,谷歌还捎带了一个私货叫Object.observe,一大堆人来捧臭脚。但其他浏览器不理帐,一如其他浏览器不想实现IE 的attachEvent, detachEvent那一套私有实现一样。每次社区兴起什么新东西,大家就开始讨论“什么时候浏览器将它们标准化呢?!”这样蠢问题。
浏览器只会实现一些低level 的API,那些高级封装的API不会出现在这列表上,要不每个元素或window上就太多方法与属性了。这也是后来虚拟DOM出现 的原因之一。说回Proxy,这是一个逆天的东西,不像Object.defineProperty,只是对=号敏感。什么delete, for in,in, new,还是方法调用都能感应到!
这是一个非常顶级的自省机制,要求浏览器对普通对象内部添加大量的钩子。JS父所在的firefox很早就搞出这东西,那些还是规范1,后来标准化后,接口有点不一样。chrome也是规范1,规范2都实现过,不过中途断线了几个版本,也是够坑的。
因此不要相信浏览器,它们不是铁板一块,并且对于它们的API也可能朝令夕改。可能那些人没有什么契约概念,总之就是坑,你不得多写个if else来做特性检测!avalon2已经使用Proxy来做VM。
Proxy的相关资料:
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
编译系统
其实写一个普通的前端模板都需要正则处理一下,当复杂如MVVM时,一个强大的parser是必须的。模板,或者叫做VM的作用区域,现在有两种流行的方式。
一是像react,reactive, regular(网易出品)那样,将它们单独抽取出来,放到script容器或什么位置上。
二是将它直接拍在页面上,与普通元素混在一起,使用ms-*,v-*, ng-*标识它们要特殊处理,然后用ms-controller, ng-controller圈定VM的作用域(vue是用ID)。各有各个好处。
独单拎出来,不用受浏览器的气,因为每个浏览器对元素的解析不太一样,比如旧式IE会将标签名大写化,布尔属性大写并没有属性值,去掉空白,有些属性没有双引号。有的浏览器对SVG元素添加很长的namespace。凡此种种,你要操碎了心。
其次可以直接用后端那一套静态模板的东西,block, macros什么的。后端模板发展很久了,搞鼓出大量神奇的东西。直接拍在页面上,是方便在切图人员的页面上修改。功能越强大,你的parser就要越强大。
后一种方式,还要符合HTML parser的一些潜规则,比如说option里面只能有文本,需要将`<`,`>`去掉。xmp,script, style, noscript里面则是一个整个文本节点。textarea的innerHTML需要取出来变成value,里面清空。table与tr中要加上tbody,像这样:
此外,什么元素下面只能有什么元素不能有什么元素,html规范是写得清清楚楚,react源码里面有一个模块([validateDOMNesting](https://github.com/facebook/react/blob/master/src/renderers/dom/client/validateDOMNesting.js))专门做这样的检测。
浏览器还能更智能帮你闭合一些元素(p什么)。一些微型的html parser就是玩具,只能算是xml parser。它们也不敢碰IE6-8这样的outerHTML,属性值可能有双引号括起,也可能用单引号括起,或干脆就没有。react的JSX其实很好写,以左花括号开始,然后深度加1,碰到另一个右花括号,深度减1,归零,左右界定符已经确定了,抽成JS逻辑部分。
接着说JS的变量抽取问题,这是将指令的表达式部分转换为求值函数的重要一步。就是依赖收集一个方法。流程大致如下:
`ms-attr="aaa+bbb+2"`得到`aaa+bbb+2`, 正则处理表达式。首先它不在注释里面,不在字符串里面,前面存在`([ { + - ,; `这些操作符,我们通过正则就可以将它们干掉,将它们替换为逗号。此外,我们只需要关注变量最前面的部分,如果后面跟着 . 号,这可能是方法或属性,这个也可以用正则将它替换为逗号。
经过这些里面,原来一段字符串,就剩下几个单词与逗号。这些单词可能存在`if, for, in, while`这个关键字,我们可以写一个hash,包含所有的关键字与保留字,这样也把它干掉。当然这里有一个难题,是正则字面量。正则字面量里面怎么写也可以。不过放心,将正则的变量也parser出来,匹配多了不会影响结果的。
```javascript
var vars = ['aaa','bbb']
```
```
var aaa = vm.aaa
var bbb = vm.bbb
```
后面加上原来的表达式
```
//body
var aaa = vm.aaa
var bbb = vm.bbb
return aaa+ bbb+ 2
```
最后放到Function构造函数中
```
var getter = Function ('vm',body)
```
上面正则的疑惑,比如说 `ms-if="/test/.test(aaa)"`
得到所有变量
```
var vars = ['test','aaa']
```
然后
```
var test = vm.test
var vm = vm.aaa
return /test/.test(aaa)
```
因此不会影响结果。
像react native加了这么多语法糖,我觉得是挺危险的。因为你无法保证几年后,那些信誓旦旦的语法是否还在不在。这东西说没就没了。你现在用的babel版本与插件要锁死版本,全部保存起来。说不定有一天从npm上也找不到,你就永远编译不了它。
因此我认为不要加这么多语法糖,加了也是框架内部处理。第三方违约的事见怪不怪。
复杂墙与性能墙
jQuery让DOM操作变得轻松愉快,因此人们可以搞出比以前复杂N倍的东西。MVVM将DOM隐蔽在VM的赋值取值之中(也是传说中的“操作数据即操作DOM”),人们更是毫无顾忌地搞更复杂的东西。就像无水泥前,人们盖草屋,有了水泥,人们建起了万神殿与圣家族大教堂,现在是在工业基础上建摩天大厦。每隔18个月,前端难度难一倍不是没有道理。
有这几个因素让大家信心爆棚,Nodejs风格的模块定义的流行,webpack,rollup的强大构建工程能力,浏览器的升级与API趋同。最重要的是MVVM摒蔽了DOM操作,就像一些护肤产品广告词一样,操作DOM如丝般细滑!以前可是披荆斩棘般的英雄诗歌,一调就调上半天。
最后是叠积木般开发,这个吹了好久了,从YUI到EXT到所有无名的UI库。现在官方引衔的Web Components,看起来还算不错,虽然当中的Shadow DOM就半死不活。那些附着于HTML的指令其实与这个规范相性很合。当然React那个也不错。现在这两种组件模式,是所有MVVM框架的抄袭对象。
复杂墙就是由组件来搞定。组件就是一堆指令的集合,及加上生命周期钩子。
此外复杂度提升,也带来性能问题。angular就有2000指令之轭。用户的每次操作是会生成大量中间变量与临时函数,即便你加了多少缓存,还是很难避免。于是有了各种办法,异步渲染(将多次VM的操作,合成一次UI渲染),密封舱机制(精准地得知那些绑定对象是发生了变化,不做无效比较),大模板函数(将整个视图编译成一个大函数,减少进入大量小函数时,重复创建作用域对象造成的消耗),最后当然还有如雷震耳的虚拟DOM。虚拟DOM是facebook发明的,是目前最好最简捷的对付性能墙的方法。
组件系统
现在两个主流组件系统的都是基于自定义标签,而不是JS类。虽然后面可能有Picker, Calendar这样的支撑类。
主要思想大概如下,通过一种特殊的tagName标识这是一个自定义标签,然后能找到后面的支撑类,然后调用其render方法,将上面传递下来VM与自带数据(props)一结合,得到一个标准的HTML元素。组件里面许多行为,是组件内部诞生的VM与外部VM共同操作,行为的实现是指令摆平。
因此我说,组件是指令的集合体。但组件的资源可能是异步的,因此组件VM可能延迟生成,并且如何拿到这个组件VM也是一个难点。你说是放到生成的HTML元素上,还是放到某个回调好。为了CG,共同的做法是放到回调里,于是有了生命周期钩子,如createdCallback, attachedCallback, detachedCallback,attributeChangedCallback。
这些东西就是从firefox的x-tag 中抄过来的。avalon2还有另一个好办法,就是操作配置对象能就操作组件(配置对象也是VM中的某个子对象)
从实现成本来说,react的成本是非常高,与JSX绑定在一起,但那个纯JS方式是不可能有人用的!
那么另一条路就是写在HTML文档的自定义标签,在旧式IE下我们可以用带命名空间的标签(一般会被识别为VML)。到IE9或其他浏览器,则是一个HTMLUnknownElement。
具体如何识别HTMLUnknownElement 可以看一下我2012年的文章
http://www.cnblogs.com/rubylouvre/archive/2012/05/02/2478461.html
到框架层面,这个识别会更简单,像React,只要是大写开头就是组件标签。avalon1.5,是带ms:开头的标签,avalon2是带ms-开头的标签或带ms-widget属性的标签。一些框架则是用is属性来做标识。
再说一下几个钩子,createdCallback,attachedCallback,attributeChangedCallback是很好实现,但移除就有点麻烦,因为许多不可控因素在里面。特别是与jQuery混用时。而浏览器提供了大量移除节点的API,搞不好就内存泄露。因此下面有个小节专门说这个。
虚拟DOM
虚拟DOM的核心思想是用轻量对象代替重型对象,承担大量原本由真实DOM参与的计算。
```
超轻量 Object.create(nulll)
轻量一般的对象 {}
重量带有访问器属性的对象, avalon或vue的VM对象
超重量各种节点或window对象
```
这其实分层架构的应用,解决不了性能问题,于是专门引进一个层来专门处理它。虚拟DOM层是一个缓冲层。经典的MVVM框架其实有个问题,为了实现最小化刷新,所有绑定对象会保留原位置上元素节点或文本节点,因此需要引入额外的机制,将无效的绑定对象进行“清洗”,要不有节点在上面会内存泄漏。虚拟DOM是不保有那些要操作节点在某个位置上。
虚拟DOM是一个树状结构,真实DOM是怎么排列,有什么属性(排除掉那些看不到方法与固有属性),虚拟DOM就是怎么排列,有什么属性。
每次VM变动,一个全新的虚拟DOM树就立即生成出来,然后新旧虚拟DOM树比较,得到patch(不同框架会做不同的优化),最后应用到真实DOM上。每次更新都是从上到下做有效的更新。而优化手段,可能是修改更新起点。比如说react,某个组件变化,它就只从其容器元素开始更新。这就要求有一种机制,识别这个属性是组件自带的属性,还是外面的VM。
为了快速将虚拟DOM树生成出来,那个编译函数自开始就是返回一个虚拟DOM,而不是一个HTML字符串。
比如这样:
它的编译函数就是这样:
现在虚拟DOM是没有统一的标准,大抵是一个普通对象,都有type(或叫tagName), props, children三个属性。为了提高比较速度,可以加上skipProps(忽略比较属性),skipContent(忽略比较子节点), outerHTML(加快比较两个组件或元素)。
然后是diff,diff属性变动或文本变动最简单。但比较子元素的排列顺序则难多了,于是react让我们为循环生成的子元素加上一个不重复的key属性,angular则使用trace by属性或函数。这是一种优化方案。但通用方案是使用最短编辑距离算法,得到所有元素的新旧位置及操作形态,
具体可以看knockout
https://github.com/knockout/knockout/tree/master/src/binding/editDetection
avalon2则什么也没用,使用另一种hashcode算法搞定的。
最后是patch了,因为真正让页面变化还是DOM操作。经过这么多胶水,DOM操作还是要做的。得益了jQuery发现那么多技巧,我们可以将相关的函数偷师到我们的框架上。
内存泄露处理与回收利用
因为MVVM内部太复杂了,如果你的parser是第三方的,组件内用到DOM操作,就有许多不可控因素了。
上面说到一个方案是,不要保留DOM节点到你的框架内,否则每次你都要做清理操作。此外还有求值函数的编译生成,Function是最好的,with有风险 ,严格模式下整个框架会挂掉。eval可能将变量变成全局的。最后是事件回调问题,绑定事件是消耗性能,移除节点不解绑事件则会内存泄漏,因此avalon2与react 使用了事件代理,于是没有这么多破事了。
上面我还说到,每个指令的表达式部分都会变成一个函数,这些函数真的每次都new吗?
其实可以缓存起来。于是就有了带数量限制的缓存体的需求。纵观这么多MVVM框架,都选择了LUR。在Sizzle里面有一个简单createCache也不错。
而每一个组件,其实也可以缓存起来。我说不单指组件的VM部分,还有对应 的DOM。比如说SPA,每一页都不一样,每个页面都有组件,当我跳到另一页做一些操作,再退回来,如果DOM缓存了,就直接插回去,不用createElement。
事件系统优化
如果翻开react的源码,你会发现至少有三分之一代码都耗在这上面。react企图使用纯JS重新实现DOM事件系统。比如说mouseenter,mouseleave,它就是比较虚拟节点的data-reactid,通过来共同祖先解决。 由于它完全与DOM绝缘,因此下面的介质从DOM换成oc,java等语言对象,也是能安稳运行起来。
不过你会发现, react的事件系统 是构建data-reactid这个UUID上。但那个事件系统不是呢!从Dean Edward大神的addEvents库起,强大的事件库都在某种UUID上构建的。UUID算法有很多种,但我们其实不需要它们。
avalon2是以函数体作为UUID!!!
```
ms-click="alert(aaa+2)"
```
将函数体取出来,去掉空白,将特殊字符取charCode,于是得到`alert40aaa43241`.然后以UUID与编译好的方法存进缓存系统。
目标元素设置一个属性,记录事件类型与UUID。将此事件绑在根节点上。当用户点击页面任何位置,就从事件源对象取这个属性,再抽取事件类型与UUID,再得到回调,传入VM与事件对象执行!
这个流程与 jQuery有点像,但简单许多。有兴趣可以看avalon2的源码
https://github.com/RubyLouvre/avalon/blob/master/src/dom/event/modern.js
diff优化
即便你不用虚拟DOM,你还是要diff,不是在真实DOM上,就是在VM上。显然在虚拟DOM上划算。既然是两棵树,diff起来有点耗时,网上也提出许多优化方案了。但虚拟DOM加一些额外的属性就能加快比较速度。上面就提到skipProp, skipContent, outerHTML。其实它们还是取出两个虚拟DOM的相关属性检测一下。还有更快的方式。
```
<div id="aaa">
<div id="bbb"ms-attr="{title:@title}"><strong>111</strong><span>222</span></div>
</div>
```
比如上面的结果,#aaa肯定要转换为虚拟DOM,因为下级有指令, #bbb也要转换,因为它有绑定属性。但strong, span可以不用转换。#bbb的innerHTML作为一个属性存于#bbb的虚拟DOM上。于是少了许多就diff, patch了。
有人说,那干脆连上面的#aaa也不转了。这样做可以,但需要你保留真实DOM到对应的绑定对象或虚拟DOM上。
我想到就这么多,有更好的技巧可告诉我。
avaon2的相关技巧
下面是安利时间。
avalon2是虚拟DOM化的avalon。avalon1由于迟迟没有实现组件,因此失去不少市场。avalon2在这上面做了大量改进。
首先avalon2的指令与ko或ng1是很相近的。不过它不使用动态依赖收集(ko,vue还在用),是使用单纯的静态词法分析。动态依赖收集太耗性能了。词法分析与上面说得差不多,不过更简单,因此现在要求在每个VM高变量前带上`@`或`##`,那样我直接正则替换@aaa为vm.aaa。因此在parser阶段,速度就像飞一样。每个指令除了ms-for,其他都是真正JS数组,对象或变量,因此转换为求值函数时少了许多破事。
创建节点上,不要用innerHTML。innerHTML其实很破的,不能创建正确的script节点,在IE下许多自定义元素创建不了,还有readonly 的问题。 react 还发现一种叫[DOMLazyTree](https://github.com/facebook/react/blob/master/src/renderers/dom/client/utils/DOMLazyTree.js)的技术,这个日后会引进来。
更新视图上,没有使用异步。因为异步不是一种友好的编程方式。react的视图更新就没有异步,它是将更新放到一个列队中,当在更新过程中又触发一个视图更新,它会此更新放到后面,执行完再执行这个。当然,这种情况是react不太愿意见到的,因此建议单向流动,不要用双绑。但即便遇上,react的更新机制还是能hold住。
组件系统上,主要onDispose钩子,如何监听一个元素是否被移出DOM,内置三种方式,MutationEvent,元素原型链属性或方法重写,及轮询。此外在onDispose对组件元素进行回收利用。
精确或近乎精确得知元素何时被移除
MutationEvent
```
function byMutationEvent(dom) {
dom.addEventListener("DOMNodeRemovedFromDocument", function (){
fireDisposeHookDelay(dom)
})
}
```
重写所有可能移除元素的原生方法或属性(appendChild, insertBefore, replaceChild, removeChild, innerHTML)。 IE9+有效!
```
//https://www.web-tinker.com/article/20618.html?utm_source=tuicool&utm_medium=referral
//IE6-8虽然暴露了Element.prototype,但无法重写已有的DOM API
varp = Node.prototype
function rewite(name, fn) {
var cb = p[name]
p[name] = function (a, b) {
return fn.call(this, cb, a, b)
}
}
rewite('removeChild', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1) {
fireDisposeHookDelay(a)
}
return a
})
rewite('replaceChild', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1) {
fireDisposeHookDelay(a)
}
return a
})
//将元素节点放到一个文档碎片上
rewite('appendChild', function (fn, a) {
fn.call(this, a)
if (a.nodeType === 1 && this.nodeType === 11) {
fireDisposeHookDelay(a)
}
return a
})
rewite('insertBefore', function (fn, a, b) {
fn.call(this, a, b)
if (a.nodeType === 1 && this.nodeType === 11) {
fireDisposeHookDelay(a)
}
return a
})
//访问器属性需要用getOwnPropertyDescriptor处理
varep = Element.prototype, oldSetter
function newSetter(html) {
var all = avalon.slice(this.getElementsByTagName('*'))
oldSetter.call(this, html)
fireDisposedComponents(all)
}
if(!Object.getOwnPropertyDescriptor) {
oldSetter = ep.__lookupSetter__('innerHTML')
ep.__defineSetter__('innerHTML', newSetter)
}else {
var obj = Object.getOwnPropertyDescriptor(ep, 'innerHTML')
oldSetter = obj.set
obj.set = newSetter
Object.defineProperty(ep, 'innerHTML', obj)
}
```
生成虚拟DOM上,也是上面提到过的大模板函数方式,直接产出虚拟DOM树。
当然还有许多细节,需要涉及框架更多东西,很能单挑出来,就不说了。有兴趣的可以看。没兴趣就罢了。编程的乐趣就看别人的源码,重造更好的轮子。只要了解许多技巧才能出奇制胜。国内也有许多人模仿vue,react造了一些MVVM框架,但没什么用。
因为思想深度不够,模仿得像画虎类猫,邯郸学步。必须深刻理解,然后出奇制胜才行。什么微创新,没什么用,程序员是很高智的群体,不会放着正宗的不用,去用山寨货。
我的分享就到这里,谢谢大家。