如果我们不借助 Vue, React 等框架,单靠原生 JS, 能不能莽出一个差不多能用的带数据绑定的页面呢?
当然,可以说 Vue 人家也是原生 JS. 这至少给了一个思路:就是,你可以从头写出一个 Vue. 所以这篇文章更像是在讨论 Vue 的(一个子集的)原理,而网上已经不缺讨论 Vue 原理的文章了。
但是其他的文章着重在讨论 Vue 是如何实现什么功能的,我们将要讨论的是如何自己实现某个功能。所以接下来的内容和 Vue 等完全没有关系。让我们抛开框架,自己造轮——即使是方形的轮子,也可以在圆形的地面上跑。
什么是数据绑定
数据绑定的思路很简单:有一个数据对象 data
, 假如是某个变量;和一个交互控件,假如是一个 <input>
. 我们希望当某段代码修改 data
的值时,也更新 <input>
中的值;而且当 <input>
中的值被用户修改的时候,data
的值也会随之修改。这个功能就是将数据 data
绑定到了 <input>
.
当然,我们希望这部分对于开发者是透明的。也就是,下面这段代码虽然能实现这个功能,但是是不能接受的:
var data = 1;
const inputNode = document.getElementById('input');
// listen for changes
inputNode.onchange = function() {
data = inputNode.value;
};
// update data
data = 2;
inputNode.value = data;
因为这样要求开发者在更改 data
的值的时候手动更新 <input>
的值。如果某次业务逻辑中忘记更新这个值,那么数据和展示就脱节了。况且,这还只是更新一个输入框的值,如果页面复杂一些,那么就需要手动维护更多的节点了。而且如果之后要更改这个输入框绑定的值,还需要再更改更新回调,十分麻烦。
引入 data-bind
我们希望能尽量少地写一些重复的代码,比如不要手动维护节点的更新回调。那么借助 HTML5 中的 data-
前缀,我们可以自定义各种属性。(我们姑且不引入编译这个步骤。毕竟上来就引入非常复杂的功能是劝退和延误的最佳方法。以及我们就不对 IE 提供支持了,这个太痛苦。)
比如,我们可以这么写:
<input id="input" name="input" data-bind="data">
然后写一个加载函数负责添加监听器:
window.onload = function () {
document.querySelectorAll('[data-bind]').forEach(node => {
let bindingProperty = node.getAttribute('data-bind');
node.addEventListener('input', function () {
window[bindingProperty] = node.value;
});
});
};
这样我们就完成了一个方向的数据流动,当然,支持得很有限。
主动全量更新
现在我们希望数据向另一个方向流动。一个显而易见的思路就是通过一个定时将变量的值应用到控件当中;控件的值更改时则将控件的值写入变量。
当然,要保证正在被用户编辑的控件的值不被改写,不然就有些滑稽了。
let controls = {};
window.onload = function() {
document.querySelectorAll('[data-bind]').forEach(node => {
let bindDataName = node.getAttribute('data-bind');
let updateList = controls[bindDataName] || [];
updateList.push(node);
controls[bindDataName] = updateList;
node.addEventListener('input', function() {
window[bindDataName] = node.value;
});
});
setInterval(function() {
Object.keys(controls).forEach(key => {
let list = controls[key];
list.forEach(node => {
if (node === document.activeElement) return;
if ('value' in node) {
node.value = window[key];
} else if ('innerText' in node) {
node.innerText = window[key];
} else {
node.innerHTML = window[key];
}
});
});
}, 500);
};
这样做有一个明显的问题:我怎么确定何时进行全量更新?现在我们在代码中设置为 500ms, 看起来似乎已经足够,但是用户会接受半秒钟的输入延迟么?或许在一些场景下这个延迟并不是特别关键,但是如果我们希望这个延迟尽量小,设置成 1ms 可以么?显然是不行的,因为有可能在 1ms 内,浏览器来不及更新所有控件。就算来得及,这样做也会大量消耗资源——毕竟大部分情况下,数据更新并不是特别频繁,所以我们一直在做无用工。
引入更动标记
我们做无用工的核心原因是:数据没有更改,但是我们仍然在重绘那些没有更改的值。能否跟踪那些值,在它们没有更改的时候不进行重绘呢?
显然是可以的:
let controls = {};
let watchingVariables = {};
window.onload = function() {
document.querySelectorAll('[data-bind]').forEach(node => {
let bindDataName = node.getAttribute('data-bind');
let updateList = controls[bindDataName] || [];
updateList.push(node);
controls[bindDataName] = updateList;
node.addEventListener('input', function() {
window[bindDataName] = node.value;
});
});
setInterval(function() {
Object.keys(controls).forEach(key => {
let oldValue = watchingVariables[key];
let newValue = window[key];
if (oldValue === newValue) return;
watchingVariables[key] = window[key];
let list = controls[key];
list.forEach(node => updateNode(node));
});
}, 500);
};
function updateNode(node, value) {
if (node === document.activeElement) return;
if ('value' in node) {
node.value = value;
} else if ('innerText' in node) {
node.innerText = value;
} else {
node.innerHTML = value;
}
}
现在,当变量不发生更改时,对 DOM 的操作将会被跳过。
但这些比较也是大部分时间无用的。能不能连比较也省略呢?比如使用回调之类的方法?
引入程序上下文
直到现在我们还是直接操作 window
内的数据(全局数据),这样做总有点污染的感觉。我们先将所有的内容包裹在一个上下文对象内,这样之后也方便进行恢复等等操作。
上面提到了回调。回调是不可能回调的,因为赋值操作没有事件,哪来的回调?
不过现在我们有了上下文对象,倒是可以自定义赋值,这样就可以省去不断检查变量是否有变更的操作了:
function ApplicationContext(initialContext) {
this._bindingList = {};
if (initialContext) {
Object.assign(this, initialContext);
}
ApplicationContext.prototype.assign = function (key, value) {
this[key] = value;
document.querySelector(`[data-bind="${key}"]`).forEach(node => updateNode(node, value));
}
ApplicationContext.prototype.bind = function (node) {
let bindingKey = node.getAttribute('data-bind');
if (bindingKey && bindingKey.trim().length > 0) {
let list = this._bindingList[bindingKey] || [];
list.push(node);
this._bidingList[bindingKey] = list;
}
}
}
现在我们的业务逻辑就可以这么写了:
const ctx = new ApplicationContext();
document.querySelector('[data-bind]').forEach(node => ctx.bind(node));
ctx.assign('data', 2);
引入赋值监听器
尽管有了上下文之后,我们可以使用 .assign
方法进行赋值,但是这个观感不太行。比起 ctx.assign('data', 1)
, ctx.data = 1
更符合直觉。能否做到后者的效果呢?
可以。通过 Proxy
类,我们可以拦截赋值行为:
function ApplicationContext(initialContext) {
// ... after .bind
this._proxy = new Proxy(this, function (target, prop, value) {
target[prop] = value;
document.querySelector(`[data-bind="${key}"]`).forEach(node => updateNode(node, value));
return true;
});
ApplicationContext.prototype.getContext() {
return this._proxy;
}
}
现在,我们就可以直接进行赋值了:
const ctx = (new ApplicationContext()).getContext();
document.querySelector('[data-bind]').forEach(node => ctx.bind(node));
ctx.data = 1;
引入对象的访问
目前而言,我们已经写出了一个差不多能用的数据绑定。现在我们试着写一些页面了:
<input id="username" name="username" data-bind="user.name">
<p>Your name is: <span data-bind="user.name"></span></p>
同时我们从后台调取用户对象 user
:
ctx.user = {
name: '(???)',
email: '(???)',
};
getUserProfile().then(result => { ctx.user = result; }).catch((e) => { ... });
这时候我们会发现数据没有更新。即使通过控制台向 ctx.user.name
赋值也不会触发更新;同时我们会发现向 ctx['user.name']
赋值会触发更新,且这个值和 ctx.user.name
并不一致。(显然!)
这个问题,我们将在下一节进行讨论。
Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.