在本系列的第一部分中,我们讨论了允许您使用构面管理不同行为的组件,以及 Milo 如何管理消息传递。
在本文中,我们将讨论开发浏览器应用程序时的另一个常见问题:模型与视图的连接。我们将揭开 Milo 中双向数据绑定的一些“魔力”,最后,我们将用不到 50 行代码构建一个功能齐全的待办事项应用程序。
关于 JavaScript 有几个误解。许多开发人员认为 eval 是邪恶的,永远不应该使用。这种信念导致许多开发人员无法确定何时可以并且应该使用 eval。
像“eval
是邪恶的”这样的咒语只有在我们处理本质上是工具的东西时才会具有破坏性。只有在给定上下文的情况下,工具才有“好”或“坏”之分。你不会说锤子是邪恶的吧?这实际上取决于你如何使用它。当与钉子和一些家具一起使用时,“锤子很好”。当用来给面包涂黄油时,“锤子不好”。
虽然我们绝对同意 eval
有其局限性(例如性能)和风险(特别是如果我们评估用户输入的代码),但在很多情况下 eval 是唯一的方法实现所需的功能。
例如,许多模板引擎在 with 运算符(开发人员中的另一个大禁忌)范围内使用 eval
将模板编译为 JavaScript 函数。
当我们思考我们想要从模型中得到什么时,我们考虑了几种方法。一种是像 Backbone 那样使用浅层模型,并在模型更改时发出消息。虽然易于实现,但这些模型的用处有限——大多数现实生活中的模型都很深。
我们考虑将纯 JavaScript 对象与 Object.observe
API 一起使用(这将消除实现任何模型的需要)。虽然我们的应用程序只需要与 Chrome 配合使用,但 Object.observe
最近才默认启用 - 之前它需要打开 Chrome 标志,这会使部署和支持变得困难。
我们想要可以连接到视图的模型,但这样我们就可以更改视图的结构,而无需更改一行代码,无需更改模型的结构,也无需显式管理视图的转换模型到数据模型。
我们还希望能够将模型相互连接(请参阅反应式编程)并订阅模型更改。 Angular 通过比较模型的状态来实现监视,对于大而深的模型来说,这变得非常低效。
经过一番讨论,我们决定实现我们的模型类,该类将支持简单的 get/set API 来操作它们,并允许订阅其中的更改:
var m = new Model; m('.info.name').set('angular'); console.log(m('.info').get()); // logs: {name: 'angular'} m.on('.info.name', onNameChange); function onNameChange(msg, data) { console.log('Name changed from', data.oldValue, 'to', data.newValue); } m('.info.name').set('milo'); // logs: Name changed from angular to milo console.log(m.get()); // logs: { info: { name: 'milo' } } console.log(m('.info').get()); // logs: { name: 'milo' }
此 API 看起来与普通属性访问类似,应该提供对属性的安全深度访问 - 当在不存在的属性路径上调用 get
时,它返回 undefined
,并且当set
被调用,它根据需要创建缺失的对象/数组树。
这个 API 是在实现之前创建的,我们面临的主要未知数是如何创建同时也是可调用函数的对象。事实证明,要创建一个返回可调用对象的构造函数,您必须从构造函数返回此函数,并同时设置其原型,使其成为 Model
类的实例:
function Model(data) { // modelPath should return a ModelPath object // with methods to get/set model properties, // to subscribe to property changes, etc. var model = function modelPath(path) { return new ModelPath(model, path); } model.__proto__ = Model.prototype; model._data = data; model._messenger = new Messenger(model, Messenger.defaultMethods); return model; } Model.prototype.__proto__ = Model.__proto__;
虽然通常最好避免使用对象的 __proto__
属性,但它仍然是更改对象实例原型和构造函数原型的唯一方法。
调用模型时应返回的 ModelPath
实例(例如上面的 m('.info.name')
)提出了另一个实现挑战。 ModelPath
实例应该具有在调用模型时正确设置传递给模型的模型属性的方法(在本例中为 .info.name
)。我们考虑通过在访问这些属性时简单地解析作为字符串传递的属性来实现它们,但我们意识到这会导致性能低下。
相反,我们决定以 m('.info.name')
返回一个对象(ModelPath
的实例)的方式来实现它们“class”),将所有访问器方法(get
、set
、del
和 splice
)合成为 JavaScript 代码并使用 m('.info.name')
返回一个对象(ModelPath
的实例)的方式来实现它们“class”),将所有访问器方法(get
、set
、del
和 splice
)合成为 JavaScript 代码并使用 eval
转换为 JavaScript 函数。
我们还缓存了所有这些合成方法,因此一旦任何模型使用 .info.name
,该“属性路径”的所有访问器方法都会被缓存,并且可以重用于任何其他模型。
get 方法的第一个实现如下所示:
function synthesizeGetter(path, parsedPath) { var getter; var getterCode = 'getter = function value() ' + '{\n var m = ' + modelAccessPrefix + ';\n return '; var modelDataProperty = 'm'; for (var i=0, count = parsedPath.length-1; i < count; i++) { modelDataProperty += parsedPath[i].property; getterCode += modelDataProperty + ' && '; } getterCode += modelDataProperty + parsedPath[count].property + ';\n };'; try { eval(getterCode); } catch (e) { throw ModelError('ModelPath getter error; path: ' + path + ', code: ' + getterCode); } return getter; }
但是 set
方法看起来更糟糕,并且非常难以遵循、阅读和维护,因为创建的方法的代码大量散布在生成该方法的代码中。因此,我们改用 doT 模板引擎来生成访问器方法的代码。
这是切换到使用模板后的 getter:
var dotDef = { modelAccessPrefix: 'this._model._data', }; var getterTemplate = 'method = function value() { \ var m = {{# def.modelAccessPrefix }}; \ {{ var modelDataProperty = "m"; }} \ return {{ \ for (var i = 0, count = it.parsedPath.length-1; \ i < count; i++) { \ modelDataProperty+=it.parsedPath[i].property; \ }} {{=modelDataProperty}} && {{ \ } \ }} {{=modelDataProperty}}{{=it.parsedPath[count].property}}; \ }'; var getterSynthesizer = dot.compile(getterTemplate, dotDef); function synthesizeMethod(synthesizer, path, parsedPath) { var method , methodCode = synthesizer({ parsedPath: parsedPath }); try { eval(methodCode); } catch (e) { throw Error('ModelPath method compilation error; path: ' + path + ', code: ' + methodCode); } return method; } function synthesizeGetter(path, parsedPath) { return synthesizeMethod(getterSynthesizer, path, parsedPath); }
事实证明这是一个很好的方法。它允许我们为我们拥有的所有访问器方法编写代码(get
、set
、del
和 splice
)非常模块化且可维护。
事实证明,我们开发的模型 API 非常有用且高性能。它演变为支持数组元素语法、数组的 splice
方法(以及派生方法,例如 push
、pop
等)以及属性/item 访问插值。
引入后者是为了避免当唯一改变的是某些属性或项目索引时合成访问器方法(这是访问属性或项目慢得多的操作)。如果模型内的数组元素必须在循环中更新,就会发生这种情况。
考虑这个例子:
for (var i = 0; i < 100; i++) { var mPath = m('.list[' + i + '].name'); var name = mPath.get(); mPath.set(capitalize(name)); }
在每次迭代中,都会创建一个 ModelPath
实例来访问和更新模型中数组元素的 name 属性。所有实例都有不同的属性路径,并且需要使用 eval
为 100 个元素中的每一个元素合成四个访问器方法。这将是一个相当慢的操作。
通过属性访问插值,此示例中的第二行可以更改为:
var mPath = m('.list[$1].name', i);
它不仅看起来更具可读性,而且速度更快。虽然我们仍然在此循环中创建 100 个 ModelPath
实例,但它们都将共享相同的访问器方法,因此我们只合成四种方法,而不是 400 个。
欢迎您估计这些示例之间的性能差异。
Milo 使用可观察模型实现了反应式编程,只要其任何属性发生变化,这些模型就会向自身发出通知。这使我们能够使用以下 API 实现反应式数据连接:
var connector = minder(m1, '<<<->>>', m2('.info')); // creates bi-directional reactive connection // between model m1 and property “.info” of model m2 // with the depth of 2 (properties and sub-properties // of models are connected).
从上面一行可以看出,由 <code class="inline">m2('.info')
返回的 ModelPath 应该具有与模型相同的 API,这意味着具有与模型相同的消息 API,也是一个函数:
var mPath = m('.info); mPath('.name').set(''); // sets poperty '.info.name' in m mPath.on('.name', onNameChange); // same as m('.info.name').on('', onNameChange) // same as m.on('.info.name', onNameChange);
以类似的方式,我们可以将模型连接到视图。组件(请参阅本系列的第一部分)可以有一个数据方面,用作 API 来操作 DOM,就好像它是一个模型一样。它具有与模型相同的 API,可以在反应式连接中使用。
例如,此代码将 DOM 视图连接到模型:
var connector = minder(m, ‘<<<->>>’, comp.data);
下面将在示例待办事项应用程序中对其进行更详细的演示。
这个连接器如何工作?在底层,连接器只是订阅连接两侧数据源中的更改,并将从一个数据源接收到的更改传递到另一个数据源。数据源可以是模型、模型路径、组件的数据方面或实现与模型相同的消息传递 API 的任何其他对象。
连接器的第一个实现非常简单:
// ds1 and ds2 – connected datasources // mode defines the direction and the depth of connection function Connector(ds1, mode, ds2) { var parsedMode = mode.match(/^(\<*)\-+(\>*)$/); _.extend(this, { ds1: ds1, ds2: ds2, mode: mode, depth1: parsedMode[1].length, depth2: parsedMode[2].length, isOn: false }); this.on(); } _.extendProto(Connector, { on: on, off: off }); function on() { var subscriptionPath = this._subscriptionPath = new Array(this.depth1 || this.depth2).join('*'); var self = this; if (this.depth1) linkDataSource('_link1', '_link2', this.ds1, this.ds2, subscriptionPath); if (this.depth2) linkDataSource('_link2', '_link1', this.ds2, this.ds1, subscriptionPath); this.isOn = true; function linkDataSource(linkName, stopLink, linkToDS, linkedDS, subscriptionPath) { var onData = function onData(path, data) { // prevents endless message loop // for bi-directional connections if (onData.__stopLink) return; var dsPath = linkToDS.path(path); if (dsPath) { self[stopLink].__stopLink = true; dsPath.set(data.newValue); delete self[stopLink].__stopLink } }; linkedDS.on(subscriptionPath, onData); self[linkName] = onData; return onData; } } function off() { var self = this; unlinkDataSource(this.ds1, '_link2'); unlinkDataSource(this.ds2, '_link1'); this.isOn = false; function unlinkDataSource(linkedDS, linkName) { if (self[linkName]) { linkedDS.off(self._subscriptionPath, self[linkName]); delete self[linkName]; } } }
到目前为止,milo 中的反应式连接已经有了很大的发展 - 它们可以更改数据结构、更改数据本身,还可以执行数据验证。这使我们能够创建一个非常强大的 UI/表单生成器,我们也计划将其开源。
你们中的许多人都会知道 TodoMVC 项目:使用各种不同的 MV* 框架制作的待办应用程序实现的集合。 To-Do 应用程序是对任何框架的完美测试,因为它的构建和比较相当简单,但需要相当广泛的功能,包括 CRUD(创建、读取、更新和删除)操作、DOM 交互和视图/模型仅举几例绑定。
在 Milo 开发的各个阶段,我们尝试构建简单的待办事项应用程序,并且毫无失败地突出了框架错误或缺点。即使深入我们的主项目,当 Milo 用于支持更复杂的应用程序时,我们也通过这种方式发现了小错误。到目前为止,该框架涵盖了 Web 应用程序开发所需的大部分领域,我们发现构建待办事项应用程序所需的代码非常简洁且具有声明性。
首先,我们有 HTML 标记。它是一个标准的 HTML 样板,带有一些样式来管理选中的项目。在正文中,我们有一个 ml-bind
属性来声明待办事项列表,这只是一个添加了 list
方面的简单组件。如果我们想要有多个列表,我们可能应该为此列表定义一个组件类。
列表中是我们的示例项,它是使用自定义 Todo
类声明的。虽然声明类不是必需的,但它使组件子组件的管理变得更加简单和模块化。
<html> <head> <script src="../../milo.bundle.js"></script> <script src="todo.js"></script> <link rel="stylesheet" type="text/css" href="todo.css"> <style> /* Style for checked items */ .todo-item-checked { color: #888; text-decoration: line-through; } </style> </head> <body> <!-- An HTML input managed by a component with a `data` facet --> <input ml-bind="[data]:newTodo" /> <!-- A button with an `events` facet --> <button ml-bind="[events]:addBtn">Add</button> <h3>To-Do's</h3> <!-- Since we have only one list it makes sense to declare it like this. To manage multiple lists, a list class should be setup like this: ml-bind="MyList:todos" --> <ul ml-bind="[list]:todos"> <!-- A single todo item in the list. Every list requires one child with an item facet. This is basically milo's ng-repeat, except that we manage lists and items separately and you can include any other markup in here that you need. --> <li ml-bind="Todo:todo"> <!-- And each list has the following markup and child components that it manages. --> <input ml-bind="[data]:checked" type="checkbox"> <!-- Notice the `contenteditable`. This works, out-of-the-box with `data` facet to fire off changes to the `minder`. --> <span ml-bind="[data]:text" contenteditable="true"></span> <button ml-bind="[events]:deleteBtn">X</button> </li> </ul> <!-- This component is only to show the contents of the model --> <h3>Model</h3> <div ml-bind="[data]:modelView"></div> </body>
为了让我们现在运行 milo.binder()
,我们首先需要定义 Todo
类。该类需要具有 item
方面,并且基本上负责管理每个 Todo
上的删除按钮和复选框。
在组件对其子组件进行操作之前,它需要首先等待对其触发 childrenbound
事件。有关组件生命周期的更多信息,请查看文档(链接到组件文档)。
// Creating a new facetted component class with the `item` facet. // This would usually be defined in it's own file. // Note: The item facet will `require` in // the `container`, `data` and `dom` facets var Todo = _.createSubclass(milo.Component, 'Todo'); milo.registry.components.add(Todo); // Adding our own custom init method _.extendProto(Todo, { init: Todo$init }); function Todo$init() { // Calling the inherited init method. milo.Component.prototype.init.apply(this, arguments); // Listening for `childrenbound` which is fired after binder // has finished with all children of this component. this.on('childrenbound', function() { // We get the scope (the child components live here) var scope = this.container.scope; // And setup two subscriptions, one to the data of the checkbox // The subscription syntax allows for context to be passed scope.checked.data.on('', { subscriber: checkTodo, context: this }); // and one to the delete button's `click` event. scope.deleteBtn.events.on('click', { subscriber: removeTodo, context: this }); }); // When checkbox changes, we'll set the class of the Todo accordingly function checkTodo(path, data) { this.el.classList.toggle('todo-item-checked', data.newValue); } // To remove the item, we use the `removeItem` method of the `item` facet function removeTodo(eventType, event) { this.item.removeItem(); } }
现在我们已经完成了设置,我们可以调用绑定器将组件附加到 DOM 元素,创建一个通过其数据方面与列表进行双向连接的新模型。
// Milo ready function, works like jQuery's ready function. milo(function() { // Call binder on the document. // It attaches components to DOM elements with ml-bind attribute var scope = milo.binder(); // Get access to our components via the scope object var todos = scope.todos // Todos list , newTodo = scope.newTodo // New todo input , addBtn = scope.addBtn // Add button , modelView = scope.modelView; // Where we print out model // Setup our model, this will hold the array of todos var m = new milo.Model; // This subscription will show us the contents of the // model at all times below the todos m.on(/.*/, function showModel(msg, data) { modelView.data.set(JSON.stringify(m.get())); }); // Create a deep two-way bind between our model and the todos list data facet. // The innermost chevrons show connection direction (can also be one way), // the rest define connection depth - 2 levels in this case, to include // the properties of array items. milo.minder(m, '<<<->>>', todos.data); // Subscription to click event of add button addBtn.events.on('click', addTodo); // Click handler of add button function addTodo() { // We package the `newTodo` input up as an object // The property `text` corresponds to the item markup. var itemData = { text: newTodo.data.get() }; // We push that data into the model. // The view will be updated automatically! m.push(itemData); // And finally set the input to blank again. newTodo.data.set(''); } });
此示例可在 jsfiddle 中找到。
待办事项示例非常简单,它仅显示了 Milo 强大功能的一小部分。 Milo 具有本文和之前的文章中未涵盖的许多功能,包括拖放、本地存储、http 和 websockets 实用程序、高级 DOM 实用程序等。
如今,milo 为 dailymail.co.uk 的新 CMS 提供支持(该 CMS 拥有数万个前端 JavaScript 代码,每天用于创建超过 500 篇文章)。
p>
Milo 是开源的,仍处于测试阶段,因此现在是尝试它甚至做出贡献的好时机。我们希望得到您的反馈。
请注意,本文由 Jason Green 和 Evgeny Poberezkin 共同撰写。
以上是实践演示:从头开始构建您自己的框架的详细内容。更多信息请关注PHP中文网其他相关文章!