控件设计的方案权衡

474 阅读4分钟
原文链接: zhuanlan.zhihu.com

1. 双向绑定

看到这个词,相信在不少人的脑子里都会冒出某种现代前端框架吧?但是扯到任何一种现代前端框架恐怕都会引战,所以我还是喜欢从原生控件说起。

原生控件就是以双向绑定的风格来设计的,比如最典型的 input[type=text]。当用户通过 UI 操作来修改其值的时候,它的 value 属性会同时发生变化;当程序修改 value 属性的时候,UI 也会发生相应变化。所以整体看起来,value 属性和 UI 的变化就是双向绑定在一起的。

我猜肯定会有人发出这样的疑问:

双向绑定的概念应该是 view 和 model 之间的绑定才对吧?

model 层应该是一个相对的概念,也许是因为写多了业务代码的缘故,在很多人心中 model 的概念都是业务上的。但作为公共控件的设计者,控件本身就是全部,所以可以认为 value 属性就是这个控件的 model 层。

接下来可能会遇到疑问二连击:

如果这么解释,那所有控件都是双向绑定了?

这个问题同样和看 model 层的角度有关。比如上面例子中的 input[type=text],如果你只关心用户输入的内容、只关心 value 属性,那确实是双向绑定的。但如果你还关心光标的位置呢?光标位置变化同样属于 UI 变化,早期的 IE 上并没有 selectionStartselectionEnd 这样的属性,所以可以说当时并不是双向绑定的。甚至早期 select 控件的 value 都不是双向绑定的。

现在大部分控件确实都是双向绑定了,因为这种设计是最便于理解和使用的。

2. 如何实现?

那么如何实现双向绑定的控件呢?

有两种方案(方向),我们通过一个例子来分析一下吧。

假如要实现一个计数按钮控件(纯粹举例子,没啥应用场景),就是一个带数字的按钮,每次点击后上面的数字自增,并且把这个数字绑定到 value 属性上。

方案 1:依赖后代的数据绑定

<script>
class Counter {
  // 从后代获取值
  get value() {
    return +this.button.textContent;
  }
​
  // 将值设置到后代上
  set value(value) {
    this.button.textContent = +value;
  }
​
  constructor() {
    let button = this.button = document.createElement('button');
    button.type = 'button';
    this.value = 0;
​
    // 用户操作直接更新到后代的 UI 上
    button.addEventListener('click', () => {
      this.button.textContent = +this.button.textContent + 1;
    });
  }
​
  renderTo(parent) {
    parent.appendChild(this.button);
  }
}
​
addEventListener('load', () => {
  let counter = new Counter();
  counter.renderTo(document.body);
});
</script>

这个方案的问题在于对 UI 的写操作不止一处,当触发 UI 变化的事件太多或 UI 变化前需要额外计算时,整个逻辑就会非常混乱。想象一下 input[type=number] 这种控件,点击和输入都可以影响 UI,而且输入的数据不仅要考虑类型,还要考虑 maxmin

方案 2:单向数据流

<script>
class Counter {
  get value() {
    return this.$value;
  } 
  
  // 所有影响 UI 的操作都从 set value 开始
  set value(value) {
    this.$value = +value;
    this.button.textContent = this.$value;
  }
  
  constructor() {
    let button = this.button = document.createElement('button');
    button.type = 'button';
    this.value = 0;
    // 用户操作不直接影响 UI,而是反馈到 set value 上
    button.addEventListener('click', () => {
      this.value++;
    });
  }
  
  renderTo(parent) {
    parent.appendChild(this.button);
  }
}
​
addEventListener('load', () => {
  let counter = new Counter();
  counter.renderTo(document.body);
});
</script>

这个方案将所有 UI 操作都放在了 set value 中,并且 get value 直接将值返回,什么也不用做,逻辑变得简单很多。

但是这个方案也是有坑点的。因为任何一个局部的 UI 变化都要考虑全局的 UI 更新。如果全局的 UI 更新是一个高成本的操作(比如超大的树形控件),或者局部 UI 高频变化(比如大量 input 事件)。那么这个方案就会导致性能非常差。

3. 总结

曾经有一段时间我很迷信单向数据流,总觉得这样可以简化逻辑,降低维护成本。直到强行用单向数据流去设计复杂控件(当时是要设计一个节点支持文本输入的树)遇到无法满足的场景之后才开始反思这两种控件设计方案的适用场景。

这篇文章是想告诉大家,控件设计的这两个方案其实是两个方向的极端,在这两个极端的中间存在着无数种方案。必要时可以考虑同时使用两种方案结合。比如在全局使用单向数据流,局部依赖后代的数据绑定加以补偿。