在本系列的第一部分中,我們討論了允許您使用構面管理不同行為的元件,以及 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 程式碼並使用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中文網其他相關文章!