如何开发自己的框架
时间:2023-03-14 20:11:47
科技观察
对于大多数开发者来说,从头开始构建框架听起来很陌生,甚至闻所未闻。这样做的人一定是疯了,对吧?当市场上已经充斥着各种各样的JavaScript框架时,我们为什么还要费心想出一套自己的东西呢?我们原本希望找到一个框架来帮助《每日邮报》网站构建一个新的内容管理系统。项目的主要目标是保证编辑过程与文章中所有元素(包括图片、嵌入对象、调出对话框等)的深度交互,实现拖拽等功能。下降操作、模拟和自我管理。今天我们可用的所有框架或多或少都基于开发人员定义的静态用户界面。我们需要可编辑的文本和动态呈现的UI元素。Backbone的定位太低级,最多只能提供基本的对象结构和消息收发机制。我们需要在此基础上建立很多抽象的关系来满足我们的实际需求。考虑到这一点,我们决定自己重建基础。我们还决定利用AngularJS框架来构建具有相对静态UI的中小型浏览器应用程序。但不幸的是,AngularJS是一个完全的黑盒环境——它没有把任何方便的API扩展或可以服务于我们直接在表上创建对象的操作机制——指令、控制器、服务都是这样。此外,虽然AngularJS可以在视图和范围表达式之间建立响应式连接,但它不允许我们以不同的模式定义这些响应式连接,因此任何中型应用程序都会变得像jQuery应用程序一样拥挤。具有事件侦听器(listener)和回调机制。两者之间的唯一区别是AngularJS框架将使用watchers而不是listeners,我们将在范围而不是DOM上操作。经过总结,我们理想的框架需要满足以下条件:?能够以声明的方式实现应用程序开发,能够为视图带来响应式的绑定模式。?能够在应用程序内的不同模型之间建立反应性数据绑定,允许以声明方式而不是强制方式管理数据扩展。?在绑定结构中插入验证和翻译机制,这样我们就可以将视图连接到数据模型,而无需通过AngularJS查看它。?精确控制哪些组件与DOM元素交互。?视图管理的灵活性允许开发人员在渲染比DOM操作更高效时使用实例中的任何模板引擎自动更改DOM并重新渲染部分。?具备动态创建用户界面的能力。?能够了解数据响应背后的深层机制并精确控制视图更新和数据流。?能够在功能上扩展框架提供的组件并创建新的组件。在现有的解决方案中,我们确实找不到能够满足我们需求的选项。在这种情况下,我们决定并行开发Milo并将其用作构建应用程序的基础。为什么叫米洛?Milo的名字取自约瑟夫·海勒(JosephHeller)的《第二十二条军规》一书中的战争商人米洛·明德宾德(MiloMinderbinder)。虽然他是在军营里当厨师起家的,但他很快就建立了自己盈利的贸易企业,并将成果“分享”给大家。作为一套框架,Milo有一个模块连接机制(Binder),可以将DOM元素连接到组件(通过特殊的ml-bind属性)。而且,这种模块连接机制还允许我们在不同的数据源之间建立实时响应的连接(Model和Data类组件就是这样的数据源)。巧合的是,Milo恰好也是MailOnline网站的缩写,如果不是为了给MailOnline创造一个独特的运行环境,我们永远不会创建这样一个框架。viewBinderMilo中的管理View是通过组件来管理的,这些组件基本上可以看成是JavaScript类的各种实例,分别管理对应的DOM元素。许多框架都使用了组件的概念以及UI元素管理,但最引人注目的无疑是ExtJS。我们已经在许多场景中使用了ExtJS(我们正在替换的遗留应用程序是用它构建的),但我们也发现了两个我们宁愿避免的陷阱。这就是binder的用武之地。首先,ExtJS不允许我们轻松管理标记。构建UI的唯一方法是将所有组件配置放在一起进行分层嵌套。这种方法给标记呈现带来了不必要的复杂性,并且开发人员失去了控制。我们需要一种方法来手动制作HTML标记并创建内联组件。Binder扫描我们的标记以查找ml-bind属性,以便它可以实例化组件并将其绑定到相应的元素。该属性包含与相应组件相关的信息;它可能包括组件类、方面和所需的组件名称。Ourmilocomponent
我们将在下一节进一步讨论facet,但现在让我们看看如何获??取此属性值并使用正则表达式提取它.varbindAttrRegex=/^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;varresult=value.match(bindAttrRegex);//resultisanarraywith//result[0]='ComponentClass[facet1,facet2]:componentName';//result[1]='组件类';//结果[2]='facet1,facet2';//结果[3]='组件名称';有了这些信息,我们接下来要做的就是遍历整个ml-bind属性,提取出这样的值,通过创建实例来管理每个元素。varbindAttrRegex=/^([^\:\[\]]*)(?:\[([^\:\[\]]*)\])?\:?([^:]*)$/;函数绑定器(回调){varscope={};//wegetalloftheelementswiththeml-bindattributevarels=document.querySelectorAll('[ml-bind]');Array.prototype.forEach.call(els,function(el){varattrText=el.getAttribute('ml-bind');varresult=attrText.match(bindAttrRegex);varclassName=result[1]||'Component';varfacets=result[2].split(',');varcompName=results[3];//assumingwehaveareregistryobjectofallourclassesvarcomp=newclassRegistry[className](el);comp.addFacets(facets);comp.name=compName;scope[compName]=comp;//wekeeparereferencetothecomponentontheelementel.___milo_component=comp;});回调(范围);}binder(函数(作用域){console.log(作用域);});所以你只需要使用正则表达式和DOM遍历,你可以使用自定义语法创建一个满足特定业务逻辑和后台需求的迷你框架。只需几行代码,我们就建立了一个架构,可以容纳模块化、自我管理的组件,并且可以随意部署。我们还可以创建一个方便的声明语法来实例化和配置HTML中的组件;但与AngularJS不同的是,我们可以根据实际需要来管理这些组件。#p#Responsibility-drivendesignExtJS不尽如人意的第二个原因是它采用的类结构在层次上极其死板和跳动,很难与我们的组件类有机结合实现。我们希望写出一套完整的特性列表,使文章中可能包含的任何组件都能找到相应的特性。例如,一个组件可以是可编辑的,可以作为一个事件被监听,甚至可以是一个拖动目标或者它本身就是一个拖动对象。这只是所需功能的一小部分示例。我们在初步列表中包括了大约十五种不同的功能类型,涵盖了给定组件可能使用的大部分功能。将这些功能整理成某种层次结构不仅令人头疼,而且严重限制了我们更改特定组件类功能的能力(我们随后投入了大量精力来处理)。鉴于此,我们决定采用另一种更灵活的面向对象设计。我们已经研究过责任驱动的设计。与定义类的特征及其包含的数据的最常见模式相反,责任驱动设计更多地关注对象负责执行的操作。由于我们正在处理复杂且不可预测的数据模型,因此这是一个很好的选择。这种类型的解决方案还允许我们稍后根据需要调整细节。在责任驱动设计中,我们摆脱了“角色”这一关键组成部分。角色是指相关职责的集合。在我们的项目中,我们将角色设置为编辑、拖放、拖动区域、可选或事件等。但是你如何在代码中体现这些角色?考虑到这一点,我们借鉴了装饰者模式。装饰器模式允许我们为单个对象添加属性,无论是静态的还是动态的,而完全不会影响同一类的其他对象的属性。虽然这个项目不需要类属性的运行时操作,但我们对这种方法提供的封装很感兴趣。Milo在具体实现过程中采用了一种混合的方式,即在组件实例上附加一个叫做facet的对象作为属性。作为一个配置对象,切面会引用组件,而组件是切面的“持有者”。这种机制允许我们为每个组件类定制构面。您可以将facets视为高级的、可配置的mixins,它们有自己的命名空间,甚至在holder对象上有自己的init方法——这需要被facet子类覆盖。functionFacet(owner,config){this.name=this.constructor.name.toLowerCase();this.owner=所有者;this.config=配置||{};this.init.apply(这个,参数);}面.prototype.init=functionFacet$init(){};所以我们可以将这个示例Facet类子类化,并为我们需要的每个特征类型创建特定的方面。Milo内置了多种不同类型的切面,例如DOM切面,负责提供一组可以在其持有者组件元素上执行的DOM功能,加上List和Item切面,它们可以共同创建一个列表重复的组件。这些方面然后由我们称为FacetedObject的类汇集在一起??,这是一个从所有组件继承的抽象类。FacetedObject还有一个名为createFacetedClass的类方法,它对自身进行子类化并将包含facets属性的所有方面附加到该类。这样,当FacetedObject转化为一个实例时,其所有的切面类都会被连接起来,通过迭代实现组件引导。functionFacetedObject(facetsOptions/*,otherinitargs*/){facetsOptions=facetsOptions?_.clone(facetsOptions):{};varthisClass=this.constructor,facets={};如果(!thisClass.prototype.facets)thrownewError('Nofacetsdefined');_.eachKey(this.facets,instantiateFacet,this,true);Object.defineProperties(this,facets);如果(this.init)this.init.apply(this,参数);functioninstantiateFacet(facetClass,fct){varfacetOpts=facetsOptions[fct];deletefacetsOptions[fct];facets[fct]={enumerable:false,value:newfacetClass(this,facetOpts)};}}FacetedObject.createFacetedClass=function(name,facetsClasses){varFacetedClass=_.createSubclass(this,name,true);_.extendProto(FacetedClass,{facets:facetsClasses});返回分面类;};在Milo中,我们还创建了一个Component基类,其中包含匹配的createComponentClass类方法以进行进一步抽象,但其基本原理保持不变。由于关键特性仍然由可配置的方面管理,我们可以在声明式风格中创建许多不同的组件类,而根本不必编写太多自定义代码。让我们看看如何使用一些可以直接在Milo中使用的切面。varPanel=Component.createComponentClass('Panel',{dom:{cls:'my-panel',tagName:'div'},events:{messages:{'click':onPanelClick}},drag:{messages:{...},drop:{messages:{...},container:undefined});在这里,我们创建了一个名为Panel的组件类,并连接了DOM函数方法,它会自动设置它的CSS类,能够监听DOM事件并在init上设置一个点击处理程序,并且可以用作拖动对象或作为拖动目标。作为最后一个方面,容器负责确保组件设置自己的范围并具有实际工作的子组件机制。Scope的下一个话题让我们讨论了很久,所有附属于文档的组件应该采用扁平结构还是树结构——在树结构中,子分支只能从父分支访问。在某些情况下,我们肯定需要作用域机制的介入,但它更多地涉及到实现层而不是框架层。比如我们有多组包含图片的图片。这些组可以轻松跟踪其子图片的当前状态,而无需触及通用范围。我们最终决定在文档中建立一套组件作用域树结构。引入作用域让很多事情更容易管理,也让我们可以使用更通用的组件命名,但这种机制显然是需要管理的。如果删除组件,则必须将其从父作用域中移除。如果移动组件,则必须将其从原始作用域中移除并将其添加到另一个作用域中。实际上,scope是一个特殊的hash或map对象,scope包含的第一个子分支具有这个对象的属性。在Milo中,范围存在于容器方面之上,它们本身几乎没有任何功能。但是scope对象有一系列不同类型的方法可以对自身进行操作和迭代。但是,为了避免名称空间冲突,这些方法的命名以下划线作为起始字符。varscope=myComponent.container.scope;scope._each(function(childComp){//迭代每个子组件});//访问作用域上的特定组件vartestComp=scope.testComp;//getthetotalnumberofchildcomponentsvartotal=scope._length();//addanewcomponentotthescopescope._add(newComp);#p#Messaging-同步和异步我们希望实现不同组件之间的松散耦合,所以我们决定将消息传递功能附加到所有组件和切面。消息机制实现的第一步是建立一套旨在管理订阅者数组的方法。方法和数组混合存在于对象中,通过该方法实现消息发送和接收功能。消息机制实现的第一步,我们使用简化版,具体代码如下:varmessengerMixin={initMessenger:initMessenger,on:on,off:off,postMessage:postMessage};functioninitMessenger(){这个。_订阅者={};}functionon(message,subscriber){varmsgSubscribers=this._subscribers[message]=this._subscribers[message]||[];如果(msgSubscribers.indexOf(订阅者)==-1)msgSubscribers.push(订阅者);}functionoff(message,subscriber){varmsgSubscribers=this._subscribers[message];如果(消息订阅者){如果(订阅者)_.spliceItem(消息订阅者,订阅者);elsedeletethis._subscribers[消息];}}functionmessagepostMessage(,data){varmsgSubscribers=this._subscribers[消息];if(msgSubscribers)msg??Subscribers.forEach(function(subscriber){subscriber.call(this,message,data);});可以使用postMessage方法实现消息的发送和接收(通过对象本身或任何其他代码),这段代码的订阅功能可以通过其他同名方法打开和关闭。现在,我们的消息机制已经具备了以下特点:?额外的外部消息源(包括DOM消息、窗口消息、数据变化和其他消息机制等)——例如,Eventsfacet通过它来显示DOM事件米洛消息机制。此功能由单独的MessageSource类及其子类实现。?定义自定义消息传递API以将消息和外部消息数据转换为内部消息。例如,Datafacet可以使用它来将更改的内容和输入的DOM事件转换为数据更改事件(详见下面的模型)。此功能在单独的MessengerAPI类及其子类之前实现。?模式订阅(使用正则表达式)例如模型(如下所示)利用内部模式订阅进行深度模型更改订阅。?使用以下语法作为订阅机制的一部分来定义上下文信息(即订阅者中的相应值):component.on('stateready',{subscriber:func,context:context});?使用once方法创建一个只发送一次的订阅机制。?将回调作为postMessage中的第三个参数传递(我们将postMessage中的参数个数视为可变的,但希望用更一致的消息传递API来替代这种可变参数机制)。?各种各样的。我们在开发消息机制的时候犯了一个严重的设计错误,就是所有的消息都会同步发送。由于JavaScript采用单线程机制,大量复杂的消息操作要执行会形成一个长长的队列,很容易造成UI卡顿。调整Milo拥有异步消息发送机制并不难(所有订阅者都使用setTimeout(subscriber,0)来接受对自己执行块的调用),改变框架和应用程序的其余部分相对困难——虽然大多数消息都可以异步发送,但有些必须同步发送(主要是那些包含数据或需要调用preventDefault的DOM事件)。默认情况下,消息现在是异步发送的,但是我们可以在发送消息时使它们同步:component.postMessageSync('mymessage',data);或者在创建订阅时:component.onSync('mymessage',function(msg,data){//...});我们做出的另一个设计决定是在使用它们的对象之上公开消费机制方法。最初,这些方法只是简单地混在对象中,但我们不想暴露所有的方法,也不可能使用独立的消息机制。因此,我们在Mixin抽象类的基础上,将消息机制调整为一个单独的类。Mixin类允许我们在宿主对象上显示某个类的方法。在这种情况下,调用这些方法时,后台仍然是Mixin,而不是宿主对象。事实证明这是一种非常方便的处理机制——我们可以完全控制需要公开哪些方法,并根据需要更改它们的名称。它还允许我们在同一对象上配备两种消息传递机制并将其用于模型。总的来说,Milo消息机制确实是一个稳定可靠的解决方案,在浏览器和Node.js中都足够了。在构成我们的产品内容管理系统套件的数万行代码中,它的使用一直非常一致。在下一篇文章中,我们将探讨Milo项目中最实用但也可能是最复杂的部分。Milo方案不仅可以让用户安全深入地访问属性,还可以从各个层面调整事件订阅。我们将一起探索实现记录,看看我们如何使用连接器对象来实现数据源的单向或双向绑定。原文链接:http://code.tutsplus.com/articles/rolling-your-own-framework--cms-21810