纯 JavaScript 中的双向数据绑定





5.00/5 (3投票s)
数据绑定方法到解决方案的详细解释和示例
引言
通过 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
视图到模型的事件处理程序。再加一条规则:将所有处理程序放在 `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` 编码字符串更新第二个输入框。第一个输入框在开始时将具有“初始值”作为其初始值。
表单将显示当前时间。类似于 这个。
现在是时候思考所做的工作并得出一些结论了
- 在纯 JavaScript 中实现双向数据绑定是可能的。
- 如果数据绑定基于 getter/setter,那么将会有很多。
- 每个字段至少需要两个事件监听器来实现双向数据绑定。
- 观察者设计模式应该有一个良好的实现。
- 代码可能会变得混乱,最好在代码结构中设置一些规则,以帮助未来的开发人员不会弄乱实现。
- 调整双向数据绑定感觉像是额外的工作,并且有将重复部分提取到单独库的倾向。但是,如果提取出来的库效果不佳,与仅仅将绑定命令保留在组件的同一个地方相比,尝试提取的难度有多大呢?
- 拥有良好的数据绑定将有助于将视图与模型解耦。
如果我们谈论解耦。这是我后悔的部分。模型与视图耦合的地方。这样做只是为了专注于数据绑定命令。对于那些一直坚持到最后的最有耐心的读者。这是一个如何将模型从视图中解耦的示例。
让我们在 `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日:初始版本