Home  >  Article  >  Web Front-end  >  Hands-on demo: Build your own framework from scratch

Hands-on demo: Build your own framework from scratch

PHPz
PHPzOriginal
2023-09-03 09:17:091017browse

Hands-on demo: Build your own framework from scratch

In the first part of this series, we discussed components that allow you to manage different behaviors using facets, and how Milo manages messaging.

In this article, we will discuss another common problem when developing browser applications: model to view connection. We'll uncover some of the "magic" of two-way data binding in Milo, and finally, we'll build a fully functional to-do application in less than 50 lines of code.

Model (or Eval is not evil)

There are several misunderstandings about JavaScript. Many developers believe that eval is evil and should never be used. This belief leads to many developers being unable to determine when eval can and should be used.

A mantra like "eval is evil" is only destructive if we are dealing with something that is essentially a tool. Tools are only "good" or "bad" given the context. You wouldn't say the hammer is evil, would you? It really depends on how you use it. "The hammer is fine" when used with nails and some furniture. "Hammers are no good" when used to butter bread.

While we absolutely agree that eval has its limitations (e.g. performance) and risks (especially if we evaluate user-entered code), in many cases eval is the only way to achieve what is needed Function.

For example, many template engines use eval within the scope of the with operator (another big no-no among developers) to compile templates into JavaScript functions.

When we thought about what we wanted from the model, we considered several approaches. One is to use a shallow model like Backbone and emit messages when the model changes. Although easy to implement, these models have limited usefulness - most real-life models are very deep.

We consider using pure JavaScript objects with the Object.observe API (which would eliminate the need to implement any models). While our app only needs to work with Chrome, Object.observe has only recently been enabled by default - previously it required the Chrome flag to be turned on, which would make deployment and support difficult.

We want a model that can be connected to a view, but so that we can change the structure of the view without changing a single line of code, without changing the structure of the model, and without explicitly managing the conversion of the view model to the data model.

We also want to be able to connect models to each other (see reactive programming) and subscribe to model changes. Angular implements monitoring by comparing the state of models, which becomes very inefficient for large and deep models.

After some discussion, we decided to implement our model class that would support a simple get/set API to operate on them and allow subscribing to changes in them:

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' }

This API looks similar to normal property access and should provide safe deep access to properties - when get is called on a property path that does not exist, it returns undefined, And when set is called, it creates the missing object/array tree as needed.

This API was created before implementation, and the main unknown we faced was how to create an object that was also a callable function. It turns out that to create a constructor that returns a callable object, you have to return this function from the constructor and also set its prototype so that it is an instance of the Model class:

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__;

Although it is generally best to avoid using the object's __proto__ property, it is still the only way to change the object instance prototype and constructor prototype.

The ModelPath instance that should be returned when calling a model (such as m('.info.name') above) presents another implementation challenge. The ModelPath instance should have a method that correctly sets the model properties passed to the model when it is called (in this case .info.name). We considered implementing them by simply parsing the properties passed as strings when accessing them, but we realized this would result in poor performance.

Instead, we decided to implement them in a way that m('.info.name') returns an object (an instance of ModelPath "class"), making all access Converter methods (get, set, del, and splice) are synthesized into JavaScript code and converted using eval JavaScript functions.

我们还缓存了所有这些合成方法,因此一旦任何模型使用 .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);
}

事实证明这是一个很好的方法。它允许我们为我们拥有的所有访问器方法编写代码(getsetdel splice)非常模块化且可维护。

事实证明,我们开发的模型 API 非常有用且高性能。它演变为支持数组元素语法、数组的 splice 方法(以及派生方法,例如 pushpop 等)以及属性/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 共同撰写。

The above is the detailed content of Hands-on demo: Build your own framework from scratch. For more information, please follow other related articles on the PHP Chinese website!

Statement:
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Previous article:Reconstruct an arrayNext article:Reconstruct an array