JavaScript 中的数据绑定(一)

/ 0评 / 0

如果我们不借助 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.