このシリーズの最初の部分では、ファセットを使用してさまざまな動作を管理できるコンポーネントと、Milo がメッセージングを管理する方法について説明しました。
この記事では、ブラウザ アプリケーションを開発する際のもう 1 つの一般的な問題、つまりモデルとビューの接続について説明します。 Milo の双方向データ バインディングの「魔法」の一部を明らかにし、最後に、完全に機能する To Do アプリケーションを 50 行未満のコードで構築します。
JavaScript についてはいくつかの誤解があります。多くの開発者は、eval は悪であり、決して使用すべきではないと信じています。この考えにより、多くの開発者は、いつ eval を使用できるのか、いつ使用する必要があるのかを判断できなくなります。
「eval
は悪である」のようなマントラは、本質的にツールであるものを扱っている場合にのみ破壊的です。ツールはコンテキストを考慮して「良い」か「悪い」かにすぎません。ハンマーが悪だとは言いませんよね?それは本当に使い方次第です。釘や一部の家具に使用する場合は、「ハンマーは問題ありません」。パンにバターを塗るのに慣れていると「ハンマーはダメ」。
私たちは、eval
には制限 (パフォーマンスなど) とリスク (特にユーザーが入力したコードを評価する場合) があることに完全に同意しますが、多くの場合、必要な機能を達成するには eval が唯一の方法です。
たとえば、多くのテンプレート エンジンは、テンプレートを JavaScript 関数にコンパイルするために、with 演算子のスコープ内で eval
を使用します (開発者の間ではこれも大きな禁止事項です)。
モデルに何を求めるかを考えたとき、いくつかのアプローチを検討しました。 1 つは、Backbone のような浅いモデルを使用し、モデルが変更されたときにメッセージを発行することです。これらのモデルは実装は簡単ですが、有用性は限られており、現実のモデルのほとんどは非常に奥深いものです。
Object.observe
API で純粋な JavaScript オブジェクトを使用することを検討します (これにより、モデルを実装する必要がなくなります)。私たちのアプリは Chrome でのみ動作する必要がありますが、Object.observe
がデフォルトで有効になったのはつい最近のことです。以前は Chrome フラグをオンにする必要があり、デプロイとサポートが困難でした。
ビューに接続できるモデルが必要ですが、コードを 1 行も変更することなく、モデルの構造を変更することなく、また、オブジェクトの変換を明示的に管理することなく、ビューの構造を変更できるようにする必要があります。ビューモデルをデータモデルに変換します。
また、モデルを相互に接続し (リアクティブ プログラミングを参照)、モデルの変更をサブスクライブできるようにしたいと考えています。 Angular はモデルの状態を比較することによってモニタリングを実装しますが、これは大規模で深いモデルの場合は非常に非効率的になります。
いくつかの議論の結果、単純な get/set API をサポートしてそれらを操作し、それらの変更をサブスクライブできるようにするモデル クラスを実装することにしました。 リーリー
この API は通常のプロパティ アクセスに似ており、プロパティへの安全なディープ アクセスを提供する必要があります。存在しないプロパティ パスでget が呼び出された場合、
unknown が返されます。
set が呼び出されると、必要に応じて欠落しているオブジェクト/配列ツリーが作成されます。
Model クラスのインスタンスになるように設定する必要があることがわかりました。
リーリー
通常、オブジェクトの
プロパティの使用を避けることが最善ですが、それでもオブジェクト インスタンス プロトタイプとコンストラクター プロトタイプを変更する唯一の方法です。
モデルを呼び出すときに返される必要がある
インスタンス (上記の m('.info.name')
など) には、別の実装上の課題があります。 ModelPath
インスタンスには、呼び出されたときにモデルに渡されるモデル プロパティを正しく設定するメソッドが必要です (この場合は .info.name
)。アクセス時に文字列として渡されたプロパティを単純に解析することでそれらを実装することを検討しましたが、これではパフォーマンスが低下することがわかりました。
代わりに、
がオブジェクト (ModelPath
"クラス" のインスタンス) を返す方法で実装することにしました。 access Converter メソッド (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 中国語 Web サイトの他の関連記事を参照してください。