65.9K
CodeProject 正在变化。 阅读更多。
Home

纯 JavaScript 中的双向数据绑定

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2020年4月12日

MIT

11分钟阅读

viewsIcon

25457

数据绑定方法到解决方案的详细解释和示例

引言

通过 JavaScript 向任何元素附加和分离事件相对容易。更具挑战性的任务是当应用程序变得更大、更混乱,并且有大量的附加/分离结构时。最终,管理一切或弄清楚某些事件处理程序的用途变得困难。事件处理程序应该有一个目的、一个计划。然而,当尝试更新或解决解决方案中的问题时,它将仅仅成为修复案例的任务,而不是花费大量时间弄清楚所有这些附加事件处理程序是做什么的。

关于代码规划和良好架构的一些话

经常出现的代码结构可以是 Ajax 请求、附加/分离事件、显示/隐藏视图等。它们很容易确定,因为它们通常成对出现。或者可能有足够的重复,导致将所有重复的相似之处过度编码到高级解决方案中。有时,这是一个好方法。有时,它可能导致过多的限制,尽管,认为所有这些部分只是复制/粘贴并稍作修改似乎不是一个坏主意。这可能是一项枯燥的任务。当需要检查代码并查找所有值时,所有相似的代码部分都可以通过查找搜索词或正则表达式轻松定位。

有一个计划总是好的。即使它很愚蠢。即使没有计划,它仍然是一个计划。计划可以在代码编写过程中出现。好的解决方案,完成的解决方案是具有从一开始就存在或在实现过程中出现的计划/想法的解决方案。

小型 JS 应用程序的宏大结构

不耐烦的读者可以跳过。主要部分在标题“引入数据绑定(更多规则/更多规划)”之后。为了使主题易于理解,让我们考虑一个小型应用程序。它如何被构建和实现?参与者有哪些?在什么地方可以更多地关注数据绑定,而更少关注其他事情。

应该有参与者的位置。参与者将通过数据交换相互通信。我们称之为应用程序。考虑一个可以过度编码的重复代码的位置。我指的是实用程序。并且应该有几个参与者来维护数据绑定。可能更像是用于构建 UI 的模板。

有助于专注于数据绑定实现的简单应用程序结构。

class App {
    // will keep the most needed part to run an application
    constructor(selector) {
    }
    // will make properties that are needed just from the beginning
    // but not a part of the constructor
    initialize() {
    }
    // will make more properties to initialize
    // can be used several times or just to easily locate when reading code
    initialize$App(el) {
    }
    // This is a helper method.
    // It would show useful information to understand the process.
    logModelState(model) {
    }
    // there we will keep stuff related to databinding
    bind(formModel) {
    }
    // clean up everything what was produced by initialize method
    remove() {
    }
}
// application entry point
setTimeout(() => {
    // There is no working code yet
    // but there is a clue how it could be used.
    // The actual application with "body" element
    const app = new App('body');
    // some models. For this example, just one.
    const model = new TimerModel();
    // This is a part to initialize the application
    app.initialize();
    // This is a part that would give application ability to read input from UI
    app.bind(model);
});

应用程序最重要的部分是用户输入。应用程序的外观。UI 将允许我们读取和处理用户输入。让我们构建一个带有几个字段和基本调试功能的小型 UI。UI 会看起来像这样。

应用程序的 HTML 部分

// template for the main form
// will hold the main UI that is not related to the subject
// but it is important as it would hold the form with fields
const mainTemplate = data => `
<div>
    <div class="form">
    </div>
    <pre class="model-state">
    </pre>
</div>
`;
// template for the form (our main focus)
// assist user input. UI that will participate with databinding
const formTemplate = data => `
<div class="form-item"><label>Enter your name: 
<input type="text" class="name" size="40" value=""/></label></div>
<div class="form-item"><label>Base64 code name: 
<input type="text" class="output" size="40" value=""/></label></div>
<div class="form-item"><span class="current-time"> </span></div>
`;

由于它是通信,因此至少应该有两个参与者。第一个是呈现表单,第二个是处理来自 UI 的输入并显示处理结果。以及一些调试信息。

视图和模型

// The first participant
// will read UI and pass data to a model
class FormView {
    // As usual, it would keep the most needed part to run Form UI
    // make events, attach event handlers
    constructor(selector) {
    }
    // Make properties that are needed from the beginning build UI
    initialize() {
    }
    // This part will make properties that are part of the UI
    initialize$FormView(el) {
    }
    // will bind UI to properties over events
    bind(model) {
    }
    // will remove properties form events
    unbind() {
    }
    // clean up everything what was produced by initialize method
    remove() {
    }
}
// The second participant
// This will hold some business logic along with databinding
class TimerModel {
    // This will make a part that is required to run Model
    // will build databinding logic here
    constructor() {
    }
    // Make more properties that are a part of the databinding
    initialize() {
    }
    // This will simulate business logic of the application
    processForm() {
    }
    // Detach event listeners
    // Removes more resources, e.g., timer function
    remove() {
        utils.getResult(this, () => this.processFormRemove);
        clearInterval(this.timer);
    }
}

主要结构已完成。然而,UI 上仍然没有任何可见内容。是时候考虑内部实现了。首先 - 显示 UI。UI 将来自模板。应该有一个处理 HTML 渲染的工具。如果存在 UI,那么我们需要定位 DOM 元素,附加/分离事件处理程序。它应该简单、多功能,也许带有新的想法。

// keeps tools that usually repeated many times in the code
// and can be extracted into the separate namespace
const utils = {
    // renders HTML from template to UI
    html(el, html) {
        // one line of implementation. For production would not be enough.
        // Looks perfect for our example.
        el.innerHTML = html;
    },
    // locates element to keep on a form object
    // the method is based on the modern Web API
    // with the best practice from jQuery
    el(selector, inst = document) {
        // it is expected that there could be passed null or undefined
        if (!selector) {
            return null;
        }
        // it is expected selector can be a string or element instance
        if ('string' === typeof selector) {
            return inst.querySelector(selector);
        }
        // if selector is instance, let's just return it
        return selector;
    },
    // attach and detach event handler to/from DOM element
    // that method will return another function to remove event handler
    // I have a long thought about what would give small code and
    // ended up with this solution
    on(inst, selector, eventName, fn) {
        // makes anonymous function
        // Smells like a potential memory leak and not convenient to use
        const handler = function (evnt) {
            // There is a catch. With this condition, it would be possible to use
            // event bubble feature. Event handler can be attached to the parent
            // element. Attaching event handlers to parent element will allow to
            // re-render internal html of the view many times without re-attaching
            // event handlers to child elements
            if (evnt.target.matches(selector)) {
                fn(evnt);
            }
        }
        // definitely it can leak memory
        inst.addEventListener(eventName, handler);
        // Let's fix inconvenience. Let's return another method that would help
        // to deal with detach handler. Now the "on" method is going to be
        // used more conveniently. But with certain approach.
        // remove event handler from the event listener element
        return function () {
            inst.removeEventListener(eventName, handler);
        }
    },
    // this is tool to evaluate method
    // if method exists on the object, it will be evaluated
    // to avoid "ifs" in the code implementation
    getResult(inst, getFn) {
        const fnOrAny = getFn && getFn();
        if (typeof fnOrAny === 'function') {
            return fnOrAny.call(inst);
        }
        return fnOrAny;
    }
};

现在我们可以构建 UI 了。让我们考虑类 `App` 和 `FormView` 的构造函数。如果这些类没有 UI,它们就没有意义。它们在方法签名中接受一个 `selector` 参数。

我们将 `this.el = utils.el(selector);` 这行代码放入它们的构造函数中

constructor(selector) {
    // It will create "el" property on the instance.
    // The main element that would hold more UI elements within.
    this.el = utils.el(selector);
}

一个小规则。如果它是处理 DOM 元素的视图类,我们将把 DOM 元素保存在 `el` 属性中。既然实例上有一个 `el` 变量,让我们用它来渲染 HTML。而且,既然是关于渲染 HTML,我不确定把它放在构造函数里面是不是一个好主意。最好有一个单独的 `initialize` 方法专门用于此目的。

`App` 类中更新的 `initialize` 方法

initialize() {
    utils.html(this.el, mainTemplate({}));
    this.initialize$App(this.el);
    this.form.initialize();
}

`FormView` 类中更新的 `initialize` 方法

initialize() {
        utils.html(this.el, formTemplate({}));
        this.initialize$FormView(this.el);
}

我想你已经注意到了另外两个新方法调用:`initialize$App` 和 `initialize$FormView`。你还记得我提到过计划吗?*无论是好是坏都无所谓。*就是这样。现在很难判断在 `initialize` 方法中为 DOM 元素构建属性有多好。我决定将这些命令分开。如果这是一个糟糕的计划,我可以重新思考并将这些方法提取到父 `initialize` 方法中。如果它是好的 - 这种结构将保留。

下面是 `initialize$App` 和 `initialize$FormView` 方法的实现

initialize$FormView(el) {
    this.name = utils.el('.name', el);
    this.output = utils.el('.output', el);
    this.currentTime = utils.el('.current-time', el);
}
...
initialize$App(el) {
    this.form = new FormView(utils.el('.form', el));
    this.modelState = utils.el('.model-state', el);
}

这两个方法都负责在实例上创建更多属性。这是唯一会构建/更新此类属性的地方。每当视图被(重新)渲染时,可以在之后调用这些方法并使用新的 DOM 元素刷新属性。

应用程序可以启动,它应该显示一个带有两个字段/文本框的简单表单。目前仅此而已。最有趣的部分将在引入数据绑定时出现。

引入数据绑定(更多规则/更多规划)

数据绑定的第一个任务是监控更改。在示例中,更改将从视图/UI 到模型,更改也将从模型到视图。简单部分是调整解决方案以接收来自视图/UI 的更改。

请注意,这不仅仅是一次性更改。有一个输入/文本框。文本可以被多次输入、删除、复制/粘贴,直到最终版本被表单接受。文本框可以有默认值。将来可能会有代码更新,例如,引入字段验证,更新 UI 设计。视图的模型可以被替换为另一个在字段中具有不同初始值的模型。视图也应该与模型良好解耦。关键是视图本身应该足够,即使它没有附加到模型上。

重点关注来自 UI 的数据,而不是从表单中查询数据的方式。下一条规则。数据可以通过 getter/setter 读取/写入。两个优点和一个缺点。容易从字段中读取/写入值,将来可以更新 UI。例如,用带有预定义值和搜索建议的复杂下拉列表替换输入文本。这将有助于保持绑定规则清晰。缺点是需要为 getter 和 setter 编写更多代码。

如果代码量增加,还有另一个规则。让我们将 getter/setter 对保持在 `constructor` 和 `initialize` 方法之间。通过这种方法,可以很容易地定位 getter/setter,并理解它们是 getter/setter,而不是其他任何东西。

输入您的姓名”和“Base64 代码名称”这两个输入的 getter 和 setter。

`FormView` 类的一部分

    getName() {
        return this.name.value;
    }
    setName(val) {
        if (val !== this.name.value) {
            this.name.value = val;
        }
    }
    getOutput() {
        return this.output.value;
    }
    setOutput(val) {
        if (val !== this.output.value) {
            this.output.value = val;
        }
    }
    setCurrentTime(val) {
        if (val != this.currentTime.innerText) {
            this.currentTime.innerText = val;
        }
    }

读写数据到 UI 很方便,无需传递包含更改数据的变量。只需传递视图并通过 getter 和 setter 引用数据就足够了。这又开启了另一种可能性。现在可以以更简单的方式编写 DOM 事件处理程序。

如果谈到事件处理程序,那将会有很多。下一个规则的好线索。这次是命名。我们按照 `on` 的模式命名事件处理程序,例如 `onInputName`。这个事件处理程序将把值从视图/UI 传递给模型。

视图到模型的事件处理程序。再加一条规则:将所有处理程序放在 `FormView` 类中的 `unbind` 方法之后。

    onInputName() {
        this.model.prop('name', this.getName());
    }
    onInputOutput() {
        this.model.prop('output', this.getOutput());
    }

`this.model` - 这是我后悔的例子。它使得视图和模型之间存在强耦合。既然是关于数据绑定,为了简洁起见,我们暂时让它与表单耦合。显然,对于实际应用程序,视图应该与模型解耦。这是一个很好的候选,可以在最后进行优化。

是时候执行第一个数据绑定命令了。将事件处理程序附加到相关元素。再来一条规则。让我们将所有正在附加且属于数据绑定的事件保留在 `bind` 方法中。

更新后的 `bind` 方法

// here, model argument will be a clue that there is a room to optimize the solution
bind(model) {
    // update data from DOM to model
    this.onInputNameRemove = utils.on(this.el, '.name', 'input', () => this.onInputName());
    this.onInputOutputRemove = 
              utils.on(this.el, '.output', 'input', () => this.onInputOutput());
}

`this.onInputNameRemove` 和 `this.onInputOutputRemove` 是从表单中分离事件处理程序的方法。它们将在 `unbind` 方法中调用。

更新后的 `unbind` 方法

unbind() {
    // detach event handlers from DOM elements
    utils.getResult(this, () => this.onInputNameRemove);
    utils.getResult(this, () => this.onInputOutputRemove);
}

从视图/UI 更新数据到模型的方式已准备就绪。这很简单,因为 DOM 元素具有 `addEventListener`/`removeEventListener` 方法。模型只是一个类。数据绑定要求模型应该有一种通知更改状态的方式。

为了方便使用,如果模型像 `utils.on` 中的 DOM 元素一样拥有 `on` 方法,那就太好了。另一个重要的方法是 `trigger`,通过该方法,模型将通知任何监听参与者有关更改的信息。尽管如此,如果任何类都可以实现通知接口,那就太好了。这对于未来会很有用。它应该很小但足以完成数据绑定。

下面的几个方法

// notify about changes
function dispatcher() {
    const handlers = [];
    return {
        add(handler) {
            if (!handler) {
                throw new Error('Can\'t attach to empty handler');
            }
            handlers.push(handler);
            return function () {
                const index = handlers.indexOf(handler);
                if (~index) {
                    return handlers.splice(index, 1);
                }
                throw new Error('Ohm! Something went wrong with 
                                 detaching unexisting event handler');
            };
        },
        notify() {
            const args = [].slice.call(arguments, 0);
            for (const handler of handlers) {
                handler.apply(null, args);
            }
        }
    }
}
// builds "on" and "trigger" methods to be used in any object
// makes events from the list of event names that are passed as arguments
// e.g. const [on, trigger] = initEvents('change:name')
function initEvents() {
    const args = [].slice.call(arguments, 0);
    const events = {};
    for (const key of args) {
        events[key] = dispatcher();
    }
    return {
        on(eventName, handler) {
            return events[eventName].add(handler);
        },
        trigger(eventName) {
            events[eventName].notify();
        }
    };
}

有了这样的工具,就可以创建事件,附加/分离事件监听器,并通知更改。第一个候选是模型。第二个是 `FormView`。

下面的代码调整 `Model` 以允许通知更改

// this is our full Model that will notify about changes
class TimerModel {
    constructor() {
        // lets construct events
        const{ on, trigger } = initEvents(this,
            'change:name', // notify name property changes
            'change:output', // notify output property changes
            'change:time' // notify time property changes
        );
        // now model will allow to trigger and subscribe for changes
        this.on = on;
        this.trigger = trigger;
        // this is internals state of the model
        this.state = {
            name: 'initial value',
            output: '',
            time: new Date()
        };
        // initialize custom business logic
        this.initialize();
    }
    initialize() {
        this.timer = setInterval(() => this.prop('time', new Date()), 1000);
        this.processFormRemove = this.on('change:name', () => this.processForm());
    }
    // probably it would be to boring to write getter/setter for every field on the model
    // here is a universal method that would serve as getter/setter for any property
    prop(propName, val) {
        if (arguments.length > 1 && this.state.val !== val) {
            this.state[propName] = val;
            this.trigger('change:' + propName);
        }
        return this.state[propName];
    }
    // custom business logic
    processForm() {
        setTimeout(() => {
            this.prop('output', btoa(this.prop('name')));
        });
    }
    // don't forget to have a method that would clean the model
    remove() {
        utils.getResult(this, () => this.processFormRemove);
        clearInterval(this.timer);
    }
}

我们用 `FormView` 也做同样的事情

class FormView {
    constructor(selector) {
        const { on, trigger } = initEvents(this,
            'change:model'
        );
        this.on = on;
        this.trigger = trigger;
        this.el = utils.el(selector);
    }
...

现在 `FormView` 可以通过更多数据绑定命令进行调整。这次是将数据从模型传输到视图。需要记住的一个优点是,任何重复的东西都很难提取到“`utility`”库中,至少要尝试将其保存在一个地方。努力不要将其分散到整个组件解决方案中。`bind`、`unbind` 方法以及随后的事件处理程序方法最好尽可能地相互靠近。这将有助于未来的维护,也许是重构。

`FormView` 类的 `bind`、`unbind` 方法和事件处理程序的完整版本

    // will bind UI to properties over events
    bind(model) {
        // update data from DOM to model
        this.onInputNameRemove = utils.on(this.el, '.name', 'input', () => this.onInputName());
        this.onInputOutputRemove = utils.on
                 (this.el, '.output', 'input', () => this.onInputOutput());
        // update data from model to DOM
        this.syncNameRemove = model.on('change:name', () => this.syncName());
        this.syncOutputRemove = model.on('change:output', () => this.syncOutput());
        this.syncCurrentTimeRemove = model.on('change:time', () => this.syncCurrentTime());
    }
    // will remove properties form events
    unbind() {
        utils.getResult(this, () => this.onInputNameRemove);
        utils.getResult(this, () => this.onInputOutputRemove);
        utils.getResult(this, () => this.syncNameRemove);
        utils.getResult(this, () => this.syncOutputRemove);
        utils.getResult(this, () => this.syncCurrentTimeRemove);
    }
    // transfer data from view/UI to model
    onInputName() {
        this.model.prop('name', this.getName());
    }
    onInputOutput() {
        this.model.prop('output', this.getOutput());
    }
    //transfer data from mode to view/UI
    syncName(evnt) {
        this.setName(this.model.prop('name'));
    }
    syncOutput(evnt) {
        this.setOutput(this.model.prop('output'));
    }
    syncCurrentTime() {
        this.setCurrentTime(this.model.prop('time'));
    }

只剩下最后一步了,虽然看起来不可能,但这会让所有的努力都奏效。首先想到的是添加更多方法。这会是 `setModel` 和 `syncModel` 方法。第一个是设置模型并在 `FormView` 上触发更改模型事件。第二个是事件处理程序。以及对构造函数方法的一个小更新,用于附加该事件处理程序。这样的事件处理程序不属于数据绑定,但将在整个 `FormView` 类中扮演重要角色。

对 `FormView` 类的更多更改

class FormView {
    constructor(selector) {
        ...
        this.sycnModelRemove = this.on('change:model', () => this.syncModel());
    }
...
    setModel(val) {
        if (val !== this.model) {
            this.model = val;
            this.trigger('change:model');
        }
    }
...
    syncModel() {
        this.unbind();
        this.setName(this.model.prop('name'));
        this.setOutput(this.model.prop('output'));
        this.model && this.bind(this.model);
    }
...

锦上添花的是更新的类“`App::bind`”方法来运行所有这些操作

class App {
...
    // there we will keep stuff related to databinding
    bind(formModel) {
        this.form.setModel(formModel);
    }
...

如果一切正确,将出现一个包含两个输入框的表单。在第一个输入框中输入内容会用第一个输入框的 `base64` 编码字符串更新第二个输入框。第一个输入框在开始时将具有“初始值”作为其初始值。

表单将显示当前时间。类似于 这个

现在是时候思考所做的工作并得出一些结论了

  1. 在纯 JavaScript 中实现双向数据绑定是可能的。
  2. 如果数据绑定基于 getter/setter,那么将会有很多。
  3. 每个字段至少需要两个事件监听器来实现双向数据绑定。
  4. 观察者设计模式应该有一个良好的实现。
  5. 代码可能会变得混乱,最好在代码结构中设置一些规则,以帮助未来的开发人员不会弄乱实现。
  6. 调整双向数据绑定感觉像是额外的工作,并且有将重复部分提取到单独库的倾向。但是,如果提取出来的库效果不佳,与仅仅将绑定命令保留在组件的同一个地方相比,尝试提取的难度有多大呢?
  7. 拥有良好的数据绑定将有助于将视图与模型解耦。

如果我们谈论解耦。这是我后悔的部分。模型与视图耦合的地方。这样做只是为了专注于数据绑定命令。对于那些一直坚持到最后的最有耐心的读者。这是一个如何将模型从视图中解耦的示例。

让我们在 `FormView` 类中进行一些代码清理任务。删除构造函数中带有事件处理程序的最后一部分,`setModel` 和 `syncModel` 方法。然后清理构造函数。并用以下代码更新 `setModel` 和 `bind` 方法

class FormView {
    constructor(selector) {
        this.el = utils.el(selector);
    }
...
    setModel(model) {
        this.unbind();
        if (!model) {
            return;
        }
        this.setName(model.prop('name'));
        this.setOutput(model.prop('output'));
        model && this.bind(model);
    }
...
    bind(model) {
        // update data from DOM to model
        this.onInputNameRemove = utils.on(this.el, '.name', 'input', 
                                 () => model.prop('name', this.getName()));
        this.onInputOutputRemove = utils.on(this.el, '.output', 'input', 
                                   () => model.prop('output', this.getOutput()));
        // update data from model to DOM
        this.syncNameRemove = model.on('change:name', () => this.setName(model.prop('name')));
        this.syncOutputRemove = model.on('change:output', 
                                () => this.setOutput(model.prop('output')));
        this.syncCurrentTimeRemove = model.on('change:time', 
                                     () => this.setCurrentTime(model.prop('time')));
    }
...

现在表单与模型的耦合度降低了。所有数据绑定命令都整合在唯一的 `bind` 方法中。

这是在 jsfiddle 中的最终结果。

历史

  • 2020年4月12日:初始版本
© . All rights reserved.