对于大多数开发人员来说,从零开始构建一套框架听起来似乎有些陌生甚至闻所未闻。这么干的家伙肯定是疯了,对吧?既然市面上已经堆满了各式各样的JavaScript框架,为什么我们还要费力搞出自己的一套东西?
我们原本希望寻找一套框架以帮助The Daily Mail网站构建起新的内容管理系统。项目的主要目标在于保证编辑流程与文章中的所有元素实现深度交互(包括图片、嵌入对象以及呼出对话框等等),并使其能够支持拖拽操作、模拟化与自我管理等功能。
当下我们所能获取到的所有框架都或多或少地以开发人员所定义的静态用户界面为设计基础。而我们则既需要可编辑文本,又需要动态渲染UI元素。
Backbone的定位实在太低级了一些,它最多只能提供基本对象结构与消息收发机制。我们需要在此基础上建立大量抽象关系才能满足自身的实际需要,考虑到这一点、我们决定自己动手重新构建基础。
我们还曾决定利用AngularJS框架来构建采用相对静态UI的中小型浏览器应用程序。不过遗憾的是,AngularJS是一套彻头彻尾的黑盒环境——它不会将任何便捷的API扩展或者能够服务于我们创建对象的操作机制直接摆在台面上——指令、控制器、服务皆是如此。再有,尽管AngularJS能够在view与scope表达之间建立响应式连接,但却不允许我们在不同模式下对这些响应式连接进行定义,因此任何一款中型应用程序都会变得像jQuery应用那样充斥着事件监听器(listener)与回调机制。二者之间的惟一区别就是,AngularJS框架会利用watcher取代listener、我们所操作的也将是scope而非DOM。
经过总结,我们理想中的框架需要满足以下条件:
• 能够以声明方式实现应用程序开发,并能为view带来响应式绑定模式。
• 能够在应用程序内的不同模型之间建立响应式数据绑定,从而通过声明而非指令方式对数据扩展加以管理。
• 在绑定结构中插入校验与翻译机制,这样我们就能将view与数据模型加以对接、而不必再通过AngularJS进行查看。
• 对与DOM元素相对接的组件进行精确控制。
• View管理的灵活性允许开发者自动操作DOM变更,并在渲染比DOM操作效率更高时利用实例中的任意模板引擎对某些部分进行重新渲染。
• 拥有动态创建UI的能力。
• 有能力触及数据响应背后的深层机制并精确控制view更新与数据流。
• 能够对该框架提供的组件进行功能性扩展,并创建新的组件。
在现有解决方案中,我们实在找不到能够满足需求的选项。在这种情况下,我们决定以并行方式开发Milo,并将其作为应用程序的构建基础。
为什么选择Milo这个名称?
之所以选择Milo这个名称,是由于Milo Minderbinder这位由Joseph Heller撰写的《第二十二条军规》一书中的战争商人。尽管是从军营中的炊事工作起步,但他很快建立起了属于自己的暴利贸易企业,并将成果与每一个人“共享”。
作为一套框架,Milo具备模块接驳机制(Binder),能够将DOM元素与组件相对接(通过特殊的ml-bind属性)。而且这套模块接驳机制还允许我们在不同数据源之间建立实时响应式连接(Model与Data类组件正是这样的数据源)。
巧合的是,Milo正好也是MaIL Online网站的缩写,而且要不是为了给Mail Online打造独特的运作环境、我们永远也不会创建出这样一套框架。
管理view
Binder
Milo当中的view由组件负责管理,而这些组件基本上可以算是JavaScript类的各项实例、分别管理与之对应的DOM元素。很多框架都会利用组件还作为UI元素管理概念,但其中最引人注目的无疑要数Ext JS。我们在很多场景中使用过Ext JS(我们要替换掉的遗留应用就是用它构建起来的),但同时也发现了两种令人避之而惟恐不速的缺陷。
This is where binder comes in.首先,Ext JS让我们无法轻松对标记加以管理。构建一套UI的惟一方式就是将所有组件配置放在一起进行分层嵌套。这种作法会给标记渲染带来不必要的复杂性,开发人员也将失去应有的控制能力。我们需要一种能够以内联方式手动制作HTML标记并创建组件的方法。
Binder会扫描我们的标记并从中查找ml-bind属性,这样它才能让组件完成实例化并将其与对应元素相绑定。该属性中包含着与对应组件相关的信息;其中可能包括组件 class、facet以及必须具备的组件名称。
- <div ml-bind=”ComponentClass[facet1, facet2]:componentName”>
- Our milo component
- </div>
我们会在接下来的部分进一步讨论facet,但目前让我们先看看该如何获取这项属性值并利用一条正则表达式将其提取出来。
- var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
- var result = value.match(bindAttrRegex);
- // result is an array with
- // result[0] = ‘ComponentClass[facet1, facet2]:componentName’;
- // result[1] = ‘ComponentClass’;
- // result[2] = ‘facet1, facet2’;
- // result[3] = ‘componentName’;
有了这些信息,我们接下来要做的就是对整个ml-bind属性进行遍历、提取此类值并通过创建实例对各个元素加以管理。
- var bindAttrRegex = /^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;
- function binder(callback) {
- var scope = {};
- // we get all of the elements with the ml-bind attribute
- var els = document.querySelectorAll('[ml-bind]');
- Array.prototype.forEach.call(els, function(el) {
- var attrText = el.getAttribute('ml-bind');
- var result = attrText.match(bindAttrRegex);
- var className = result[1] || 'Component';
- var facets = result[2].split(',');
- var compName = results[3];
- // assuming we have a registry object of all our classes
- var comp = new classRegistry[className](el);
- comp.addFacets(facets);
- comp.name = compName;
- scope[compName] = comp;
- // we keep a reference to the component on the element
- el.___milo_component = comp;
- });
- callback(scope);
- }
- binder(function(scope){
- console.log(scope);
- });
所以只需要利用正则表达式与DOM遍历,大家就可以利用自定义语法创建出符合特定业务逻辑与背景需求的迷你框架。只需少许代码,我们已经设置出一套能够容纳模块化、自我管理组件的架构,并能够随意加以运用。我们还可以创建出便捷的声明式语法、从而对HTML中的组件进行实例化及配置;不过与AngularJS不同,我们能够根据实际需求对这些组件进行管理。
#p#
由职责驱动的设计方案
Ext JS令人不满的第二大原因在于,它所采用的类结构在分层方面极具刚性且存在跳跃性,这使其很难与我们的组件类实现有机结合。我们希望编写出一套全面的特性列表,一篇文章中可能包含的任何组件都能从中找到对应特性。举例来说,某个组件具备可编辑特性,可以作为事件加以监听,甚至能够成为拖拽目标或者本身就属于拖拽对象。这还只是所需特性当中的一小部分。我们在初步名单中包含了大约十五种不同的功能类型,囊括了多数特定组件有可能用到的特性。
将这些特性整理成为某种分层式结构不仅仅令人头痛,同时也会给我们对特定组件类的功能变更带来严重局限(我们随后投入大量精力处理这个问题)。有鉴于此,我们决定采取另一套更具灵活性的面向对象设计方案。
我们已经了解过由职责驱动的设计方案。与最常见的类及其所包含数据特性定义模式相反,职责驱动设计更多将关注重点放在对象负责执行的操作身上。由于我们需要处理的是复杂度颇高且无法预测的数据模型,因此这种方式无疑非常合适。这类解决方案还允许我们日后根据实际需要对细节加以调整。
在职责驱动设计当中,我们摒弃了“角色(Role)”这一关键性组成部分。一个角色指的是一系列相关责任的集合。在我们的项目当中,我们将角色设定为编辑、拖拽、拖拽区、可选或者事件等等。但大家要如何将这些角色体现在代码当中?考虑到这一点,我们借鉴了装饰者模式的作法。
装饰者模式允许我们将特性添加到单一对象当中,无论静态或者动态,而且完全不会对同一类中其它对象的特性造成影响。尽管类特性的运行时操作在此次项目中并非必要,但我们对这种处理思路所提供的封装方式很感兴趣。Milo在具体实施过程中采取了混合型方式,即将被称为facet的对象作为属性附加到该组件实例当中。作为配置对象,facet会引用该组件、而组件则是facet的“持有者”。这种机制允许我们根据每个组件类对facet加以定制。
大家可以将facet视为高级、可配置混合类型,它们在持有者对象上拥有自己的命名空间甚至是自己的init方法——该方法需要被该facet子类所覆盖。
- function Facet(owner, config) {
- this.name = this.constructor.name.toLowerCase();
- this.owner = owner;
- this.config = config || {};
- this.init.apply(this, arguments);
- }
- Facet.prototype.init = function Facet$init() {};
因此我们可以将这个示例Facet类划入子类并为自己需要的每种特性类型创建特定facet。Milo预先内置有多种不同类型的facet,例如DOM facet、负责提供能够在其持有者组件元素上执行的DOM功能集合,外加List与Item facet、二者能够共同创建出重复组件列表。
这些facet随后会被我们称为的FacetedObject类汇聚到一起,这是一个抽象类、继承自全部组件。FacetedObject还拥有一个名为createFacetedClass的类方法,能够将其自身划入子类并将所有包含facets属性的facet附加至该类。通过这种方式,当FacetedObject转化为实例后就会接入其全部facet类,并可以通过迭代方式实现组件引导。
- function FacetedObject(facetsOptions /*, other init args */) {
- facetsOptions = facetsOptions ? _.clone(facetsOptions) : {};
- var thisClass = this.constructor
- , facets = {};
- if (! thisClass.prototype.facets)
- throw new Error('No facets defined');
- _.eachKey(this.facets, instantiateFacet, this, true);
- Object.defineProperties(this, facets);
- if (this.init)
- this.init.apply(this, arguments);
- function instantiateFacet(facetClass, fct) {
- var facetOpts = facetsOptions[fct];
- delete facetsOptions[fct];
- facets[fct] = {
- enumerable: false,
- value: new facetClass(this, facetOpts)
- };
- }
- }
- FacetedObject.createFacetedClass = function (name, facetsClasses) {
- var FacetedClass = _.createSubclass(this, name, true);
- _.extendProto(FacetedClass, {
- facets: facetsClasses
- });
- return FacetedClass;
- };
在Milo中,我们还创建出一个包含匹配createComponentClass类方法的基础Component类、旨在进一步实现抽象化,不过其基本原则仍然保持不变。由于关键特性仍然由可配置facet负责管理,我们可以在一种声明样式内创建多种不同组件类,而且完全不必编写太多自定义代码。下面来看如何在Milo当中使用一部分可以直接使用的facet。
- var Panel = Component.createComponentClass(‘Panel’, {
- dom: {
- cls: ‘my-panel’,
- tagName: ‘div’
- },
- events: {
- messages: {‘click’: onPanelClick}
- },
- drag: {messages: {...},
- drop: {messages: {...},
- container: undefined
- });
在这里,我们已经创建出一个名为Panel的组件类且已经与DOM功能方法相对接,它会在init上自动设置其CSS类、能够监听DOM事件并在init上设置点击处理程序、能够作为拖拽对象也可以作为拖拽目标。作为最后一个facet,container负责确保该组件对自己的scope进行设置并拥有实际起效的子组件机制。
Scope
接下来的话题让我们讨论了很长一段时间,即附加至文档的所有组件到底应该采用扁平化结构还是树状结构——在树状结构中,子分支只能接受来自父分支的访问。
在某些情况下,我们肯定需要scope机制的介入,但其更多涉及到的是实施层而非框架层。举例来说,我们拥有多套包含有图片的图片组。这些组能够轻松追踪其子图片的当前状态,而无需涉及通用scope。
我们最终决定在文档中建立一套组件scope树状结构。引入scope能让很多工作变得更易于打理,也让我们可以使用更多通用组件命名方式,但这种机制显然需要加以管理。如果大家删除了某个组件,则必须将其从父scope当中移除出去。如果大家对某个组件进行移动,则必须将其由原scope内移除再添加至另一个scope当中。
事实上,scope是一种特殊的散列或者映射对象,scope当中容纳的第一个子分支都拥有该对象的属性。在Milo当中,scope存在于容器facet之上,而后者本身几乎没有任何功能性可言。不过scope对象却拥有一系列不同类型的方法,能够对自身进行操作与迭代。但为了避免命名空间冲突,这些方法在命名时都会以下划线作为起始字符。
- var scope = myComponent.container.scope;
- scope._each(function(childComp) {
- // iterate each child component
- });
- // access a specific component on the scope
- var testComp = scope.testComp;
- // get the total number of child components
- var total = scope._length();
- // add a new component ot the scope
- scope._add(newComp);
#p#
消息收发——同步与异步
我们希望能在不同组件之间实现松散耦合,因此我们决定将消息收发功能附加到所有组件与facet之上。
消息机制实施工作的第一步在于建立起方法集合,旨在对订阅者数组进行管理。方法与数组以混合方式存在于对象当中,并借此实现消息收发功能。
对于消息机制实施方案的第一步,我们姑且采用一套简化版本,具体代码如下所示:
- var messengerMixin = {
- initMessenger: initMessenger,
- on: on,
- off: off,
- postMessage: postMessage
- };
- function initMessenger() {
- this._subscribers = {};
- }
- function on(message, subscriber) {
- var msgSubscribers = this._subscribers[message] =
- this._subscribers[message] || [];
- if (msgSubscribers.indexOf(subscriber) == -1)
- msgSubscribers.push(subscriber);
- }
- function off(message, subscriber) {
- var msgSubscribers = this._subscribers[message];
- if (msgSubscribers) {
- if (subscriber)
- _.spliceItem(msgSubscribers, subscriber);
- else
- delete this._subscribers[message];
- }
- }
- function postMessage(message, data) {
- var msgSubscribers = this._subscribers[message];
- if (msgSubscribers)
- msgSubscribers.forEach(function(subscriber) {
- subscriber.call(this, message, data);
- });
- }
任何使用这种混合类型的对象都能利用postMessage方法自行实现消息收发(由对象本身或者任何其它代码实现),而该代码的订阅功能可通过其它拥有相同名称的方法实现开启与关闭。
现在,我们的消息机制已经具备以下特性:
• 附加外部消息来源(包括DOM消息、窗口消息、数据变更以及其它消息机制等)——例如由Events facet利用其通过Milo消息机制来显示DOM事件。这项功能依靠单独的MessageSource类及其子类实现。
• 定义定制化消息收发API,从而将消息与外部消息数据翻译成内部消息。例如Data facet就能利用其翻译变更内容,并将DOM事件输入到数据变更事件(详见下文model)当中。这项功能领先单独的MessengerAPI类及其子类实现。
• 模式订阅(利用正则表达式)例如model(如下所示)利用内部模式订阅以实现深层model变更订阅。
• 利用以下语法作为订阅机制的组成部分,进而实现背景信息(即订阅者中的对应值)定义:
- component.on('stateready',
- { subscriber: func, context: context });
• 利用once方法创建只发送一次的订阅机制。
• 在postMessage中将回调作为第三项参数进行传递(我们将postMessage当中的参数数量视为变量,但希望能用更为一致的消息收发API来代替这种变量参数机制)。
• 其它。
我们在开发消息机制时犯下了一个严重的设计错误,即所有消息都会被同步发出。由于JavaScript采用单线程机制,大量有待执行的复杂消息操作会构成长队列,而且很容易造成UI卡死。通过调整让Milo拥有异步式消息发送机制并非难事(所有订阅者利用setTimeout(subscriber, 0)接受其自身执行block的调用),变更框架及应用程序的其余部分则相对更难——虽然大多数消息都能够以异步方式发送,但也有一些必须采用同步发送机制(主要是那些内部包含数据或者需要调用preventDefault的DOM事件)。在默认情况下,消息现在会以异步方式发送,但我们可以利用以下方式在消息发出时使其遵循同步机制:
- component.postMessageSync('mymessage', data);
或者在创建订阅时:
- component.onSync('mymessage', function(msg, data) {
- //...
- });
我们作出的另一项设计决策是将消费机制方法显示在使用它们的对象之上。最初,这些方法只是简单被混杂在对象当中,但我们并不希望将所有方法都显示出来、而且也不可能使用彼此独立的消息机制。因此我们通过调整让消息机制作为以Mixin抽象类为基础的单独类。
Mixin类允许我们将某个类的各项方法显示在宿主对象之上,在这种情况下,当这些方法被调用时、其背景仍然是Mixin而非宿主对象。
事实证明这是一套非常便捷的处理机制——我们可以对需要显示的方法进行全面控制,并根据需要变更其名称。它还允许我们在同一对象上配备两种消息机制,并将其用于model。
总体而言,Milo消息机制确实是一套稳定可靠的解决方案,而且足以在浏览器内以及Node.js中发挥作用。在构成我们这套产品内容管理系统的数万行代码中,它一直拥有相当出色的使用频率。
下期预告
在下一篇文章中,我们将探讨Milo项目最实用但可能也是最复杂的部分。Milo方案不仅允许用户安全而且深入地进行属性访问,同时也能够从任意层级对事件订阅作出调整。
我们将一同探索实施记录,了解我们如何利用connector对象实现数据源的单向或者双向绑定。
原文链接:http://code.tutsplus.com/articles/rolling-your-own-framework--cms-21810