双向数据绑定的3种实现方式

写在前面

从没有状态,到手动维护状态,操作DOM更新视图,再到后来的双向数据绑定,各种前端解决方案一直在追求简化数据与视图之间的关联方式,目前的趋势是弱化DOM,强化数据状态,关注“真正的”逻辑

一.里程碑

Server-Side Rendering: Reset The Universe

There is no change. The universe is immutable.

没有状态,弱前端

视图控制、逻辑功能都由服务端提供,前端仅处理表单检验之类的交互,直接把用户行为告诉服务端:

前端:点下一步按钮了,前端卒。
服务:好,这是下一页

逻辑状态都放在服务端,前端代码就一行if (isValid) form.submit(); else alert('invalid')

P.S.好久没见过ssr了…

First-gen JS: Manual Re-rendering

I have no idea what I should re-render. You figure it out.

前端维护状态,手动操作DOM更新视图

前端代码越来越多,需要维护一些状态,这个时期的前端框架(例如Backbone.js, Ext JS, Dojo)想要按照“最佳实践”分离MVC:

前端框架:给我数据,给我模版,我帮你渲染
前端er:好,给你
前端框架:用户刚才点按钮了,我不知道哪块要重新渲染,你自己来吧
前端er:document.getXXX().xxx()

框架帮忙分离数据和视图,后续状态更新需要手动操作DOM,因为框架只管首次渲染,不追踪状态监听变化

Ember.js: Data Binding

I know exactly what changed and what should be re-rendered because I control your models and views.

手动操作DOM太麻烦,想办法简化

框架想要弱化DOM操作,只关注数据,那么就要知道数据到视图的映射关系:

前端框架:给我数据,给我模版,我帮你渲染
前端er:好,给你
前端框架:不是这个,数据必须用我给你的盒子装起来
前端er:好了,给你
前端er:我改数据了
前端框架:收到,页面内容已经更新了

框架提供数据模型,把数据包起来,这样后续的增删改都必须走框架API,框架就知道数据变了,更新对应的视图,框架监听了数据变化

AngularJS: Dirty Checking

I have no idea what changed, so I’ll just check everything that may need updating.

用框架提供的数据模型太麻烦了,想办法直接追踪状态变化

前端框架:给我数据,给我模版,我帮你渲染
前端er:好,给你
前端er:帮我监听data.name,变的时候更新h1的文本内容
前端框架:好
前端框架:变了,我知道怎么做,之前是xxx,现在是aaa,那么把h1内容改成aaa

框架记下上次的值,在数据可能发生了变化时(DOM操作或者手动通知),取最新的值和上次的比较,不一样就用最新值更新视图,保证视图状态和数据状态一致

React: Virtual DOM

脏检查太费劲(有风吹草动就要全检查一遍),想别的办法

I have no idea what changed so I’ll just re-render everything and see what’s different now.

不喜欢麻烦的数据模型,嫌脏检查太傻,看能不能不监听数据变化:

前端框架:给我数据,给我模版,我帮你渲染
前端er:好,给你
前端er:我换data了,你看着更新视图
前端框架:好,从这里向下通知,子孙们自己看要怎么更新,简单的(属性更新)自己做,麻烦的(结构更新)告诉我
前端框架:哦,删掉一个span,插入2个li,o了

框架不主动追什么时候数据变了,需要手动通知框架状态变化,框架向下一看就知道该怎么做

Vue

监听数据变化,最简单粗暴的方式难道不是定义setter

但是定义setter没办法监听所有数据变化,可是又能满足大部分场景,少数场景的话,限制一下:

前端框架:给我数据,给我模版,我帮你渲染
前端er:好,给你
前端er:data.value = 'new value'
前端框架:setter说值变了,我更新一下视图
前端er:data.newKey = 'new key value'
前端框架:zzZ
前端er:$set('newKey', 'new key value')
前端框架:添新属性了,我更新一下视图

框架追踪数据变化,更新视图,少数场景需要手动通知状态变化

二.setter监听变化

Vue采用这种方式,基本思路如下:

  1. 数据-视图:遍历data定义setter,在setterupdate view
  2. 视图-数据:监听input等需要双向绑定的元素,监听相关事件,在handlerupdate data

视图-数据的处理很简单,没有选择,也没有争议。数据-视图需要考虑setter无法应对的场景:

  • 给对象添新属性(data.newKey = 'value')时,setter监听不到

  • delete删掉现有属性,setter监听不到

  • 数组变化监听不到

第1个问题在ES6 Proxy能用之前无法解决Object.observe(), Array.observe()已经废弃了),所以需要提供额外API支持手动通知添加,例如:

var data = {a: 1};
var vm = new Vue({
  data: data
});
// 通过额外API通知框架添新属性了
vm.$set('b', 2);

第2个问题无关紧要,需要delete的场景很少,赋值undefined再添一点额外判断就能避免delete

第3个问题比较严重,而且不好解决,Vue通过篡改原生Array方法(把能改变数组内容的数组方法都注入一遍)来弥补

/**
 * Intercept mutating methods and emit events
 */
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator () {
    //...
    //注入监听部分
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

摘自https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js

这样把通过数组方法改变数组内容的情况也收进来了,还存在2个问题:

  • arr.length=3这样修改长度引起的数据变化监听不到

  • arr[0] = 1这样通过索引修改值的变化也监听不到

像监听对象那样定义lengthsetter?不行,数组的length不允许通过Object.defineProperty()篡改:

var a = [1, 2, 3];
Object.defineProperty(a, 'length', {
    set(newValue) {
        console.log('change to ' + newValue);
        return newValue;
    },
    get: x => x
});
// Uncaught TypeError: Cannot redefine property: length

通过length修改数组内容可以用其它方式代替(比如splice),不是非常关键,这个就先不管了

通过索引修改数组元素是很常用的方式,必须处理,所以要添加额外的API,支持修改数组指定元素:

// 老早的版本在监听的数组原型上添了$set()和$remove()
// 现在好像没了,用全局的Vue.set()
Vue.set(vm.arr, 0, 'setted')

这样数据变化监听才算基本完备

尝试实现

数组稍微麻烦些就先不管了,这里实现inputdiv与简单数据对象的双向绑定

关键的setter监听变化部分:

var bind = function(node, data) {
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data-bind key');

    // cache
    var cache = cacheData(data);

    // data to view
    if (cache._cachedNodes) {
        cache._cachedNodes.push(node);
    }
    else {
        cache._cachedNodes = [node];
        // setter: data to view
        Object.defineProperty(data, key, {
            enumerable: true,
            set: function(newValue) {
                cache[key] = newValue;
                // update view
                updateView(cache, newValue);
                return cache[key];
            },
            get: function() {
                return cache[key];
            }
        });
    }

    // init view
    updateView(cache, cache[key]);
};
// bind
bind($input, data);
bind($output, data);

setterupdate view,完成数据-视图的绑定

然后监听input元素的input事件,完成视图-数据的绑定:

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')] = this.value;
});

当然,实际场景要考虑inputcheckboxselect等各种视图变化会影响状态的情况

直接修改数据,会被setter发现,更新视图:

// 手动改变量值
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完整Demo地址:http://ayqy.net/temp/data-binding/setter.html

三.提供数据模型

Ember.js采用这种方式

由框架提供一套数据模型,把实际数据包起来,后续更新必须走数据模型API,框架就拿到了所有数据变化,从而完成数据-视图的绑定。比较老的方式,用起来很麻烦,没什么好说的

尝试实现

同样,这里尝试实现inputdiv与字符串的双向绑定

先提供数据模型,在数据模型内部更新视图:

// 提供数据模型
var MString = function(str) {
    this.value = str;
    this.nodes = [];
};
MString.prototype = {
    bindTo: function(node) {
        this.nodes.push(node);
        this.updateView();
    },
    updateView: function() {
        var VALUE_NODES = ['INPUT', 'TEXTAREA'];

        var nodesLen = this.nodes.length;
        if (nodesLen > 0) {
            for (var i = 0; i < nodesLen; i++) {
                if (VALUE_NODES.indexOf(this.nodes[i].tagName) !== -1) {
                    // 避免input赋值,光标跳到末尾
                    if (this.nodes[i].value !== this.value) {
                        this.nodes[i].value = this.value;
                    }
                }
                else {
                    this.nodes[i].innerText = this.value;
                }
            }
        }
    },
    set: function(str) {
        if (str !== this.value) {
            this.value = str;
            // update view
            this.updateView();
        }
    },
    get: function() {
        return this.value;
    }
};

提供数据模型其实相当于定义setter,同样为了监听变化,提供一套数据模型是万无一失的,支持的数据操作都可以监听到,不支持的也不可能引起变化,所以处理起来相当简单

然后建立联系:

// setter: data to view
data[$input.getAttribute('data-bind')].bindTo($input);
data[$output.getAttribute('data-bind')].bindTo($output);

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')].set(this.value);
});

后续数据更新需要走数据模型API:

// 手动改变量值
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完整Demo地址地址:http://ayqy.net/temp/data-binding/model.html

四.脏检查

Angular采用这种方式

不监听数据变化,只时不时的去检查数据变了没,变了就更新对应的视图

那么问题是:

  • 什么时候检查?

    可能引起数据变化的时候都去检查,比如DOM操作后,交互事件发生后,觉得数据可能不一致了,就检查一遍看变了没

  • 如何得知哪块变了?

    不知道哪块变了,所以要把所有绑定到视图的数据都检查一遍,当然,“所有”不是指整页,只是组件级的($scope),所以性能虽然不很好,但多数场景下无大碍

如果手动修改了数据,希望立即更新视图,而脏检查机制在将来某个时机才会执行,无法满足,就必须手动执行脏检查,此时就不那么方便了

尝试实现

仿照Angular风格做一套简单的:

var Scope = function() {
    this.$$watchers = [];
};

Scope.prototype.$watch = function(watchExp, listener) {
    this.$$watchers.push({
        watchExp: watchExp,
        listener: listener || function() {}
        // 之后的脏检查会添一个last属性,用来缓存oldValue
    });
};

Scope.prototype.$digest = function() {
    var dirty;

    do {
        dirty = false;

        // 遍历watcher,检查last有没有变脏
        for(var i = 0; i < this.$$watchers.length; i++) {
            // 用取值方法watchExp重新取一次值
            // 不直接记key取,更灵活些(可以在watchExp里手动包装一个新值,不关心值在谁身上)
            var newValue = this.$$watchers[i].watchExp(),
                oldValue = this.$$watchers[i].last;

            if(oldValue !== newValue) {
                // 记下的last变脏了,触发回调
                // 第一次digest时,last是undefined,所以至少会执行一次listener
                this.$$watchers[i].listener(newValue, oldValue);
                // 控制外层待会儿再检查一遍,看执行listener有没有引起别的变化
                //! 只要发现脏东西,就要再检查一遍,确保digest完毕后last和数据一致
                dirty = true;
                // 更新缓存值
                this.$$watchers[i].last = newValue;
            }
        }
    } while (dirty);
};

watch()时记下取值方法和回调函数,digest()过程遍历watcher重新取值检查缓存值脏不脏

一次遍历中只要发现一点脏东西,就要再检查一遍,直到确定数据与缓存值完全一致,那么肯定存在死循环的情况(两个数据变化相互关联),所以至少应该加上最大连续检查次数限制(TTL),避免死循环,这里嫌麻烦就不添了

P.S.详细的Angular实现解析见参考资料

用脏检查机制实现数据-视图的绑定:

var bind = function(scope, node) {
    scope._nodes = scope._nodes || [];
    scope._nodes.push(node);
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data bing key');

    // init view
    updateView(scope, scope[key]);

    // data to view
    scope.$watch(function(){
        return scope[key];
    }, function(newValue, oldValue) {
        // console.log(oldValue, newValue);
        updateView(scope, newValue);
    });
};
// bind
bind($scope, $input);
bind($scope, $output);

同样,再实现视图-数据的绑定:

// view to data
$input.addEventListener('input', function() {
    $scope.value = $input.value;
    $scope.$digest();
});

私自修改数据,想立即更新视图的话,要手动执行脏检查:

// 手动改变量值
$('#btn').onclick = function() {
    $scope.value = 'updated value ' + Date.now();
    $scope.$digest();
};

完整Demo地址:http://ayqy.net/temp/data-binding/dirty-checking.html

五.虚拟DOM

3种数据绑定方式上面已经介绍完了,因为虚拟DOM不能算作另一种实现双向数据绑定的方式(虽然虚拟DOM做到了单向数据绑定)

React采用这种方式,考虑虚拟DOM树的更新:

  • 属性更新,组件自己处理

  • 结构更新,重新“渲染”子树(虚拟DOM),找出最小改动步骤,打包DOM操作,给真实DOM树打补丁

单纯从数据绑定来看,React虚拟DOM没有数据绑定,因为setState()不维护上一个状态(状态丢弃),不追踪变化,谈不上绑定

从数据更新机制来看,React类似于提供数据模型的方式(必须通过state更新)

P.S.结构更新也可以说是创建一棵子树,与现有子树做diff,记下最小改动步骤,打包DOM操作更新真实DOM。但实际上是在一次递归向下检查过程中,边更新虚拟DOM树边记录最小改动步骤的,所以上面弱化两棵树做diff,强调向下检查子树

没有双向数据绑定的话,input场景要怎么实现?

var App = React.createClass({
  render: function() {
    return (
      <div>
        <input type="text" value={this.state.text} onChange={this.onChange} />
        <div>{this.state.text}</div>
      </div>
    )
  },
  getInitialState: function() {
    return {text: this.props.text}
  },
  onChange: function(e) {
    this.setState({
      text: e.target.value
    });
  }
});

ReactDOM.render(
  <App text="hoho" />,
  document.getElementById('container')
);

通过框架提供的API,手动通知数据变化,和操作DOM的方式很像:

var text = 'hoho';
window.onInput = function(e) {
    text = e.target.value;
    document.getElementById('output').innerText = text;
};

document.getElementById('container').innerHTML = '<div><input type="text" value="' + text + '" oninput="onInput(event)" /><div id="output">' + text + '</div></div>';

六.更新效率优化

首次渲染好说,后续更新时都面临怎样把数据更新对应到真实DOM更新的问题

React中,setState()不关注数据是不是之前的,变了还是没变,只要传入了状态,就丢弃上一个状态。也就是说,把原数据对象原封不动的再setState()一遍,也必须向下检查整棵子树,才能得到数据没变,不需要更新视图的结论。因为状态丢弃机制,不追踪数据变化细节,即便传入同一份数据,也没办法立即确定状态没变

Vue也面临这样的问题,但情况会好一些,setter发现变化后,不知道这次的状态更新对子树的影响。但维护数据状态、追踪变化方案的明显优势就是知道每份数据所关联的视图,也就是说与指定数据相关的真实节点列表是已知的,直接更新依赖这份数据的所有真实节点就好(当然,还需要考虑状态“剧烈”变化时维护更新不如整个替换的优化),所以Vue watcher有依赖收集机制,就是为了加快向下检查(非依赖项不检查)

Angular需要检查当前$scope$$watchers数组,才能确定状态变没变,这样就做了很多没必要的检查,一个简单值属性更新,可能只对应一个文本节点,也要把$scope下所有数据-视图的绑定关系全检查一遍。因为也是状态丢弃机制,脏检查只关心值,不关心数据结构,手动修改数据后,通知脏检查说变了,脏检查只能挨个再取一遍值比较,因为脏检查不知道刚才修改的数据与脏检查要取的值之间的对应关系,无法缩小范围

React的话,有简单的优化方案,可以跳过没变的数据检查:用不可变的数据结构,比如Immutable.js

这样不用挨个检查state的属性才能确定没发生变化,直接先equals()看看,相等就没必要往下了

Om就是这样做的:

I know exactly what didn’t change.

用不可变的数据结构能够快速排除砍掉没变的,得到发生变化的部分,提升diff效率

P.S.当然,脏检查用不可变的数据结构没有意义,还是得遍历取值才知道变了没,脏检查压根不关注数据结构。setter监听变化用不可变数据结构的话,就太不灵活了,而且监听的粒度不够细,丧失了优势。至于提供数据模型的方式,就更不用说了,因为数据模型都不可变了,哪有变化需要监听

七.参考资料

这次的参考资料有很高的参考价值,建议接着挨个当正文看

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code