@网络老鼠技术小屋

网络老鼠技术小屋-涂飞平的博客空间

avalon代码导读

2 年前 0

avalon这个前端框架在大半年前就接触过,当时正在为一个产品做技术选型,由于产品对数据展现和操作(绑定)有着很多要求,所以决定采用MVVM框架来降低开发时候的代码量,当时主要考虑了angular,vue.js,avalon几个框架。由于angular使用范围广,后台硬(google),所以选择了它作为产品的前端框架。综合后期开发的表现,表现非常不错,但同时也存在学习曲线较高的问题,以至于直到目前,系统中,对angular相关的指令的扩展,服务编写,还只能由我来完成,这点比较坑。
Avalon官网【传送门】

    avalon是一个功能强大,体积小巧的MVVM框架。它遵循“操作数据即操作DOM”的理念,让你在代码里基本见不到一点DOM操作代码。
    DOM操作全部在绑定后,交给框架处理。相当后端有了ORM一样,不用你手写SQL,提高生产力!
与其他MV*相比,它不仅轻量,最低支持到IE6,而且性能是最好的。
    avalon作者司徒正美 ,目前由“去哪儿”前端团队维护。

最近在帮朋友做一个小系统的管理模块,又让我想起万能的MVVM框架了,但这次我不打算采用angular了,原因很简单:这个产品不是面向特定用户,是部署在互联网上的,需要照顾到IE早期版本 :-(,采用avalon貌似是唯一的方案了(因为大部分MVVM框架大都使用了defineProperty/defineProperties来实现对变量监控机制,对IE有版本要求)。由于avalon代码量不大(ver1.471 小于6000行),正好负责的产品处于bug修正阶段,抽了2天时间阅读完全部的源码。通过阅读源码,可以达到如下目的:一、可以更加熟悉avalon的各种特性;二、学习了js前端框架的设计套路(推荐avalon作者司徒正美 写的书JavaScript框架设计 );三、理解MVVM后面的一些设计理念。
本小文只是作为前段时间代码阅读的一个笔记的简单整理,针对的是1.471版本。
428527486439276922.jpg
整个代码可以分为3大部分,按代码顺序为:1、模型数据绑定;2、框架指令解析;3、AMD支持和配置系统。
面对avalon代码,我们该如何看呢?分享我阅读代码的一个方法:如何用,就如何看,通过标准用法来确定阅读的流程。
典型的avalon代码的编写是这样的(这里为了简化,先不使用avalon的AMD载入方式)
avalon.config({debug: false});
var vm = avalon.define("controller_name", function($scope){
$scope.init = function(){};
});
avalon.scan();//这行代码在实际使用时可以不写,框架会自动执行
几行代码就可以概括avalon整个执行过程。avalon.config主要是对avalon进行一些全局设置,比如设置debug,设置值绑定解析符号(默认是{{,}});avalon.define是模型定义,该函数返回一个vmodel,以后我们就使用这个vmodel。avalon.scan是指令编译(也就是通过扫描dom,解析指令)。我们按照这个顺序来阅读代码。
在avalon.js中搜索define定义,可以找到如下代码:
var VMODELS = avalon.vmodels = {} //所有vmodel都储存在这里
avalon.define = function (id, factory) {
var $id = id.$id || id
if (!$id) {
log("warning: vm必须指定$id")
}
if (VMODELS[$id]) {
log("warning: " + $id + " 已经存在于avalon.vmodels中")
}
if (typeof id === "object") {
var model = modelFactory(id)
} else {
var scope = {
$watch: noop
}
factory(scope) //得到所有定义

model = modelFactory(scope) //偷天换日,将scope换为model
factory(model)
}
model.$id = $id
return VMODELS[$id] = model
}

函数支持angular方式的定义和对象方式定义(新版本1.5仅支持对象定义方式,个人觉得不好,后面会说说这部分)。
代码比较简单,通过id类型判断采用何种定义方式:
avalon.define("xxx", function(){})
avalon.define({$id:"", xxx: function(){}})
不管是我们自己给出定义对象还是让avalon给我们生成一个对象(scope),最后都是通过modelFactory函数,返回一个model,并且最后define函数返回的就是这个model,所以,关键部分就是modelFactory函数。
注意,这里定义的对象最后都被丢弃了,函数会将生成的model再次交给factory函数执行,完成真正的绑定。
VMODELS最后会通过avalon.vmodels暴露出来给外部查询使用。
modelFactory函数是一个比较大的函数,将主要的拿出来分析
    var names = Object.keys(source)
names.forEach(function (name, accessor) {
var val = source[name]
$model[name] = val
if (isObservable(name, val, $skipArray)) {
//总共产生三种accessor
$events[name] = []
var valueType = avalon.type(val)
//总共产生三种accessor
if (valueType === "object" && isFunction(val.get) && Object.keys(val).length <= 2) {
accessor = makeComputedAccessor(name, val)
computed.push(accessor)
} else if (rcomplexType.test(valueType)) {
accessor = makeComplexAccessor(name, val, valueType, $events[name], $model)
} else {
accessor = makeSimpleAccessor(name, val)
}
accessors[name] = accessor
}
})
将传入的source(就是函数scope,或者自定义对象参数)属性全部获取($skipArray指定的属性除外,所以如果不需要绑定的属性,方法,可以放在$skipArray中),并将其重新设置到$model上,在设置过程中,会为每个属性生成访问器(属性,方法访问hook),然后返回的就是$model对象。原来的对象实际上就完成了历史使命了。这就是为什么avalon要求先定义再使用 的原因(后续加入的属性由于没有经历这个过程,所以监控机制对其无效)!
1.5为了计算属性设置,抛弃了factory方式定义,但为什么我不赞成新版本中仅支持对象定义方式呢?看下面的代码
var obj = {
$id: "testcontroller",
val: "hello"
};
avalon.define(obj);
obj.val = "world";

上面这个代码看起来完全没有问题吧?在用户不是太明白机制的情况下,肯定会写出类似代码。但由于obj在define过程中最后被丢弃了,所以对于obj的任何改变都不会反应到dom元素中,而在factory定义方式中,用户一般不会写出类似的代码。
回到代码中,Accessor是完成各种属性,变量绑定的核心,它会根据浏览器的版本不同生成不同的变量,属性访问器函数。当model中的变量发生变化,这些Accessor就能感知变化并通过事件通知DOM元素。
20151201145202.png
完成了属性访问器的汇总,将这些属性访问器和设置好的model都统一到vmodel上面
$vmodel = defineProperties($vmodel, descriptorFactory(accessors), source)
至于函数defineProperties,可以参考ES5中的defineProperties,如果是早期IE(ES5传送门
到这里,不需要看生成三种Accessor的代码细节,也知道其作用了,将变量的读取和写入操作都监控起来(这些变量的任何风吹草动都是调用相应的accessor的结果)。
在accessor调用的过程中,会触发notify,发出通知,之后绑定到变量的DOM组件会相应通知并作出反馈,之后,会看到DOM如何响应notify的。
accessor._name = name
//同时更新_value与model
accessor.updateValue = globalUpdateValue
accessor.notify = globalNotify
注意globalNotify函数,之后会再说(globalUpdateValue函数其实就是直接赋值)。
完成属性监控设置后,就是为vmodel添加一些特定的属性和方法,将vmodel对象绑定到全局EventBus上(所有的事件响应,调用,均通过EventBus来处理)。最后返回vmodel,define函数结束。
hideProperty($vmodel, "$id", generateID())//hideProperty只是个语义函数,其实就是挂接属性
hideProperty($vmodel, "$model", $model)
hideProperty($vmodel, "$events", $events)
for ( i in EventBus) {
hideProperty($vmodel, i, EventBus[i].bind($vmodel))
}
到目前为止,给定的object所有(需要监控的)属性都在我们的监控范围内,任何对属性的读取和写入的操作都会被我们首先获取,这是MVVM中双向绑定中属性变量监控的基础。

--------------------我是华丽的分隔符--------------------

对象Model的工作结束,接下来就是将Model与DOM的Element关联起来。整个关联操作是通过使用avalon(ms-xxxx)指令来完成的。
对于指令的解析操作,都是通过avalon.scan函数来完成,正如函数名表达的意义,通过扫描DOM来解析其中包含的avalon指令,然后完成DOM事件到MVVM变量监控的绑定操作。

avalon.scan = function(elem, vmodel) {
elem = elem || root
var vmodels = vmodel ? [].concat(vmodel) : []
scanTag(elem, vmodels)
}
如果没有DOM Element参数,就用root(也就是document),这里也支持传入特定的vmodel作为当前DOM Element关联的作用域。
然后,调用scanTag扫描DOM Element下面包含的所有标签。
function scanTag(elem, vmodels, node) {
var a = elem.getAttribute("ms-skip")
if (!elem.getAttributeNode) {
return log("warning " + elem.tagName + " no getAttributeNode method")
}
var b = elem.getAttributeNode("ms-important")
var c = elem.getAttributeNode("ms-controller")
if (typeof a === "string") {
return //如果是ms-skip,该Element不扫描了
} else if (node = b || c) {
var newVmodel = avalon.vmodels[node.value]
if (!newVmodel) {
return
}
//ms-important不包含父VM,ms-controller相反
vmodels = node === b ? [newVmodel] : [newVmodel].concat(vmodels)
var name = node.name
elem.removeAttribute(name)
avalon(elem).removeClass(name)
createSignalTower(elem, newVmodel)
}
scanAttr(elem, vmodels) //扫描特性节点
}
代码很简单(对,我只贴简单的代码 :-)),按照指令优先级,顺序扫描,然后解析,如果是ms-controller或者ms-important,则会将vmodel稍作处理(controller的范围),然后调用scanAttr函数继续扫描,scanAttr是一个大函数,里面主要完成这么几个工作:
1、扫描DOM Element所有的ms-开头的属性,如果该属性有相应的bindingHandler方法,就生成一个binding对象
2、将所有的binding对象收集起来,按照优先级排序(指令的优先级)。
3、调用函数executeBindings方法,通过调用相应的bindingHandler方法来完成事件的绑定。

简化后的代码如下

 for (var i = 0, attr; attr = attributes[i++]; ) {
if (attr.specified) {
if (match = attr.name.match(rmsAttr)) {
//如果是以ms-前缀命名的
var type = match[1]
msData[name] = value
if (typeof bindingHandlers[type] === "function") { //有对应的bindingHandler
var newValue = value.replace(roneTime, "")
var oneTime = value !== newValue
var binding = {
type: type,
param: param,
element: elem,
name: name,
value: newValue,
oneTime: oneTime,
uuid: name + "-" + getUid(elem),
//chrome与firefox下Number(param)得到的值不一样 #855
priority: (priorityMap[type] || type.charCodeAt(0) * 10) + (Number(param.replace(/\D/g, "")) || 0)
}
if (type === "html" || type === "text") {
var token = getToken(value)
avalon.mix(binding, token)
binding.filters = binding.filters.replace(rhasHtml, function () {
binding.type = "html"
binding.group = 1
return ""
})// jshint ignore:line
} else if (type === "duplex") {
var hasDuplex = name
} else if (name === "ms-if-loop") {
binding.priority += 100
}
bindings.push(binding) //全部加入bindings数组中
}
}
}
}
if (bindings.length) {
bindings.sort(bindingSorter)//按优先级高低排序
executeBindings(bindings, vmodels)
}
}
最后继续扫描子节点。
scanNodeList(elem, vmodels)
scanNodeList最后会对子节点继续调用scanTag或者scanText继续扫描。递归整个过程以完成所有DOM节点的扫描工作。
现在上面的代码,bindings的作用是什么,bindingHandler和executeBindings分别做了什么?
binding对象将会出现在后面所有重要函数中(以参数形式),它携带有当前DOM Element所有重要属性信息:指令名称,指令值,指令类型,Element节点,指令优先级等,后续函数会根据不同指令,在传递的过程中还会附加一些特定的属性。
一个DOM Element可能存在多个指令,每个指令都有一个binding对象
通过这些携带信息的bindings对象,调用executeBindings来继续工作,现在我们基本上可以猜测到executeBindings函数的作用了,它就是来完成DOM Element事件绑定的。
具体如何做?
function executeBindings(bindings, vmodels) {
for (var i = 0, data; data = bindings[i++]; ) {
data.vmodels = vmodels
bindingHandlers[data.type](data, vmodels)
}
bindings.length = 0
}
通过传递的bindings参数,来安装事件处理例程,这里为什么不按照DOM属性出现的顺序来安装呢?因为指令是有优先级,有作用范围的,相同的指令在不同的作用域中,必须得到正确的处理,所以在一个Element中,先收集指令,然后统一处理才是正确的。
executeBindings本身基本上没有做什么工作,都是交给特定的bindingHandlers函数来处理的。这是可以理解的,对于不同的DOM Element,事件方式是不完全一致的,比如INPUT和SELECT就不一样,INPUT中,button和radio又不一样(一个触发的是click,一个触发的是change事件)。
avalon.js代码中,从3395行开始到4800,包含大量的各种bindingHandler(bindingHandlers是一个大的map,保存着所有的bindingHandler)。
现在拿一条具体的指令来说明bindingHandler后续处理,比如下面这个指令。
当scan执行到这个指令的时候,最后会找到bindingHandlers.duplex并调用(因为ms-duplex对应binding的type是duplex),我们看看它是如何处理表达式ms-duplex="value"的。
根据上面代码分析,参数binding中的name是ms-duplex,value就是value字符串,element就是input本身。
var duplexBinding = bindingHandlers.duplex = function(data, vmodels) {
var elem = data.element,
parseExprProxy(data.value, vmodels, data, 1)//后面会单独说
data.changed = getBindingCallback(elem, "data-duplex-changed", vmodels) || noop
if (data.evaluator && data.args) {
//省略对binding对象添加各种特定属性
duplexBinding[tagName] && duplexBinding[tagName](elem, data.evaluator.apply(null, data.args), data)
}
}
上面省略了data(也就是binding)参数各种针对诸如radio,select的特定设置。
duplexBinding的核心代码如下(仅拿一种情况解析)
     //当value变化时改变model的值
var updateVModel = function () {
var val = element.value //防止递归调用形成死循环
var lastValue = data.pipe(val, data, "get")
if ($elem.data("duplexObserve") !== false) {
evaluator(lastValue)
callback.call(element, lastValue)
}
}
//当model变化时,它就会改变value的值
data.handler = function () {
var val = data.pipe(evaluator(), data, "set")
if (val !== element.oldValue) {
element.value = element.oldValue = val
}
}
bound("click", updateVModel)//这里仅仅是radio的处理
注意这里的evaluator是binding对象evaluator,它会在表达式解析(上段代码的parseExprProxy函数,稍后会讲到)的过程中生成,基本上就是一条简单的对表达式的封装,包括Get和Set的语句。最后将事件绑定到data上(因为data已经包含了element信息,所以绑定到element上也就顺理成章了)。
对于data.hander回调,什么时候会被调用呢?
记得第一部分绑定代码分析中,最后所有的vmodel的变化,都会发出通知,globalNotify,而代码到这里,出现这么一句:
avalon.injectBinding(data)
根据函数名,猜测着Notify和Handler两者应该会在这里有勾稽关系。
avalon.injectBinding = function (data) {
var valueFn = data.evaluator
if (valueFn) { //如果是求值函数
dependencyDetection.begin({
callback: function (vmodel, dependency) {injectDependency(vmodel.$events[dependency._name], data)}}/*重点是这句*/
})
将data注入到vmodel的$event特定事件中。
每个vmodel包含有完整的$event事件链再回过头来看globalNotify的代码:
function globalNotify(vmodel, value, oldValue) {
var name = this._name
var array = vmodel.$events[name] //刷新值
if (array) {
fireDependencies(array) //同步视图
EventBus.$fire.call(vmodel, name, value, oldValue) //触发$watch回调
}
}
非常简单的代码,就是获取vmodel中特定的$event对象数组,然后调用fireDependencies来更新,这个函数肯定会调用上面注册的handler方法了。最后还会触发用户注册的对应名称的watch回调函数。
fireDependencies简化后的代码(简化后的都是重点)
function fireDependencies(list) {
if (list && list.length) {
var args = aslice.call(arguments, 1)
for (var i = list.length, fn; fn = list[--i]; ) {
var el = fn.element
if (el && el.parentNode) {
var valueFn = fn.evaluator
var value = valueFn.apply(0, fn.args || [])
fn.handler(value, el, fn)/*重点*/
}
}
}
}
}
这里的list数组就是之前的bindings对象(闭包持有)。
代码的最后一行,就是调用之前data.handler函数了。

现在就比较清楚了,当model发生变化,通知机制会触发相应DOM关联的handler,而当DOM Element的value发生变化(可能是事件,可能是变化),会触发注册的DOM事件,事件本身又通过事件通知,最后调用evaluator函数来更新model。到这里,DOM到变量的双向绑定就完成了!
20151201184528.png

这里还有一个比较重要的过程,就是在绑定前,对于绑定值表达式的计算(就是如何生成data.evaluator),比如,我们会写如下代码:

{{val + " is good!"}}或者
这里就要求输出的内容必须是:变量 + " is good!"了。
对于表达式的支持,在evaluator中会根据解析过程中,就需要生成正确的函数并设置到之前binding.evaluator中。各种表达式的解析都是通过函数来执行,然后反馈到DOM Element中。
function parseExprProxy(code, scopes, data, noRegister) {
code = code || "" //code 可能未定义
parseExpr(code, scopes, data)
if (data.evaluator && !noRegister) {
data.handler = bindingExecutors[data.handlerName || data.type]
avalon.injectBinding(data)
}
}
注意其中调用的parseExpr即可。parseExpr函数是表达式解析的关键。它是通过正则表达式,将我们表达式中的代码元素分割为变量,值等,然后匹配vmodel中的变量,生成相应的存取函数表达式,继而生成函数对象,并赋值给data.evaluator(简化后代码)。
fn = Function.apply(noop, names.concat("'use strict';\n" + prefix + code))
data.evaluator = evaluatorPool.put(exprId, function() {
return fn.apply(avalon, arguments)//确保可以在编译代码中使用this获取avalon对象
})
--------------------我是华丽的分隔符--------------------
最后一部分是AMD规范的支持和配置,这部分代码基本上没有什么可以讲解的,理解了AMD规范,代码就可以很容易看懂了。看代码的顺序也与实际使用顺序一致:
require.config --> innerRequire.config
require.define --> innerRequire.define
require --> innerRequire
主要看对应的innerRequire代码即可,代码量也不大。
Understanding AMD & RequireJS 传送门

这就是两天看代码的笔记,限于篇幅(其实是我不愿意码字),有些地方写得比较粗略,但通过这个脉络,看代码应该会更有的放矢。
写得匆忙,当然,看得也很匆忙,如有错误,欢迎指出。
再说说我的感受吧,整个avalon代码量不大,该有的,该支持的都有了,非常方便,虽然表达式部分采用查找替换方式,较之angular直接编译方式显得不那么高大上,但对于简单的表达式也够用(仅仅支持简单表达式),到目前也没有发现有什么不妥的地方。avalon对于与其他库的使用,持完全开放的态度,它的vmodel是暴露出来的(也可以通过api获取到其他的vmodel),可以在外面随便调用(这点比angular简便很多)。
优点很多,不一而足,但这个库也有一些小的问题:能看出来,有很多地方有修补的痕迹(类似hack代码),代码的规范不是很好,大部分语句没有使用分号分隔(个人认为,这不是好习惯)。
20151201195925.png补上一幅图,核心函数调用步骤,很简单,知道函数名及脉络,便于查看定位代码。
最后说明:此文不是教您如何使用avalon,使用可以参考avalon帮助 ,此文仅仅是给想读avalon代码的朋友提供一个大纲而已。

编写评论