请严格把这个问题当作教育问题来对待。我仍然有兴趣听到新的答案和想法来实现这一点

博士tl;

如何用JavaScript实现双向数据绑定?

到DOM的数据绑定

通过数据绑定到DOM,我的意思是,例如,拥有一个带有属性b的JavaScript对象a。然后有一个<input> DOM元素(例如),当DOM元素改变时,a也会改变,反之亦然(也就是说,我的意思是双向数据绑定)。

下面是AngularJS的一个图:

JavaScript是这样的:

var a = {b:3};

然后输入(或其他形式)元素,如:

<input type='text' value=''>

我希望输入的值是a.b的值(例如),当输入文本发生变化时,我希望a.b也发生变化。当JavaScript中的a.b发生变化时,输入也会发生变化。

这个问题

在纯JavaScript中完成这个任务的基本技术是什么?

具体来说,我想要一个好的答案参考:

对象绑定是如何工作的? 倾听形式上的变化是如何起作用的? 是否有可能以一种简单的方式只在模板级别修改HTML ?我不想在HTML文档本身中跟踪绑定,而只在JavaScript中跟踪绑定(使用DOM事件,JavaScript保持对所使用的DOM元素的引用)。

我都试过什么?

我是一个大粉丝的胡子,所以我尝试使用它的模板。然而,我在尝试执行数据绑定本身时遇到了问题,因为Mustache将HTML处理为字符串,所以在我得到它的结果后,我没有引用到我的视图模型中的对象在哪里。我能想到的唯一解决办法是用属性修改HTML字符串(或创建DOM树)本身。我不介意使用不同的模板引擎。

基本上,我有一种强烈的感觉,我把手头的问题复杂化了,有一个简单的解决办法。

注意:请不要提供使用外部库的答案,特别是那些有数千行代码的答案。我用过(而且喜欢!)AngularJS和KnockoutJS。我真的不想要“使用框架x”这种形式的答案。最理想的情况是,我希望未来不知道如何使用许多框架的读者能够自己掌握如何实现双向数据绑定。我不期待一个完整的答案,但希望能让人理解。


当前回答

迟来的派对,特别是因为我在几个月/几年前写了两篇相关的lib,我以后会提到它们,但看起来仍然与我有关。简短地说,我选择的技术是:

模型观测的代理 用于跟踪DOM更改的MutationObserver(用于绑定原因,而不是值更改) 值的变化(从视图到模型流)通过常规的addEventListener处理程序来处理

恕我直言,除了OP之外,重要的是数据绑定实现将:

处理不同的应用生命周期情况(HTML先,然后JS, JS先,然后HTML,动态属性变化等) 允许模型深度绑定,这样就可以绑定user.address.block 数组作为模型应该得到正确的支持(移位、拼接等) 处理ShadowDOM 尽量简化技术替换,因此任何模板子语言都是一种不适合未来更改的方法,因为它与框架耦合得太紧密了

考虑到所有这些因素,在我看来不可能仅仅抛出几十行JS代码。我试着把它作为一种模式而不是自由——对我没用。

接下来,拥有Object。observe被删除了,但是考虑到模型的观察是至关重要的部分——这整个部分必须被关注——分离到另一个库中。现在说到我如何处理这个问题的原则——正如OP所问的那样:

模型(JS部分)

我对模型观察的看法是代理,恕我直言,这是唯一可行的方法。 功能齐全的观察者应该有自己的库,所以我开发了对象观察者库。

模型应该通过一些专用的API注册,这就是pojo变成可观察对象的地方,在这里看不到任何快捷方式。DOM元素被认为是绑定视图(见下文),首先用模型的值更新,然后在每次数据更改时更新。

视图(HTML部分)

恕我直言,表达绑定最简洁的方式是通过属性。很多人以前这样做过,很多人以后也会这样做,所以这里没有新闻,这是一种正确的方法。在我的例子中,我使用了以下语法:<span data-tie="modelKey:path.to。data => targerProperty"></span>,但这并不重要。对我来说重要的是,HTML中没有复杂的脚本语法——这是错误的,再说一次,恕我直言。

首先要收集指定为绑定视图的所有元素。在我看来,从性能角度来看,管理模型和视图之间的一些内部映射是不可避免的,似乎是牺牲内存+一些管理来节省运行时查找和更新的正确情况。

视图首先从模型中更新(如果可用的话),然后根据后来的模型更改进行更新,正如我们所说的。 而且,应该通过MutationObserver观察整个DOM,以便对动态添加/删除/更改的元素做出反应(绑定/取消绑定)。 此外,所有这些都应该复制到ShadowDOM中(当然是开放的),以避免留下未绑定的黑洞。

具体细节还可以进一步列出,但在我看来,这些是实现数据绑定的主要原则,一方面是功能的完整性,另一方面是简单性。

因此,除了上面提到的对象观察器之外,我实际上还编写了数据层库,它按照上面提到的概念实现了数据绑定。

其他回答

在过去的7年里,情况发生了很大的变化,我们现在在大多数浏览器中都有本地web组件。在我看来,问题的核心是在元素之间共享状态,一旦你有了它,当状态改变时更新ui就很简单了,反之亦然。

为了在元素之间共享数据,你可以创建一个StateObserver类,并以此扩展你的web组件。一个最小的实现是这样的:

// create a base class to handle state class StateObserver extends HTMLElement { constructor () { super() StateObserver.instances.push(this) } stateUpdate (update) { StateObserver.lastState = StateObserver.state StateObserver.state = update StateObserver.instances.forEach((i) => { if (!i.onStateUpdate) return i.onStateUpdate(update, StateObserver.lastState) }) } } StateObserver.instances = [] StateObserver.state = {} StateObserver.lastState = {} // create a web component which will react to state changes class CustomReactive extends StateObserver { onStateUpdate (state, lastState) { if (state.someProp === lastState.someProp) return this.innerHTML = `input is: ${state.someProp}` } } customElements.define('custom-reactive', CustomReactive) class CustomObserved extends StateObserver { connectedCallback () { this.querySelector('input').addEventListener('input', (e) => { this.stateUpdate({ someProp: e.target.value }) }) } } customElements.define('custom-observed', CustomObserved) <custom-observed> <input> </custom-observed> <br /> <custom-reactive></custom-reactive>

小提琴在这里

我喜欢这种方法,因为:

no dom traversal to find data- properties no Object.observe (deprecated) no Proxy (which provides a hook but no communication mechanism anyway) no dependencies, (other than a polyfill depending on your target browsers) it's reasonably centralised & modular... describing state in html, and having listeners everywhere would get messy very quickly. it's extensible. This basic implementation is 20 lines of code, but you could easily build up some convenience, immutability, and state shape magic to make it easier to work with.

将变量绑定到输入的一个简单方法(双向绑定)是直接访问getter和setter中的输入元素:

var variable = function(element){                    
                   return {
                       get : function () { return element.value;},
                       set : function (value) { element.value = value;} 
                   }
               };

在HTML中:

<input id="an-input" />
<input id="another-input" />

并使用:

var myVar = new variable(document.getElementById("an-input"));
myVar.set(10);

// and another example:
var myVar2 = new variable(document.getElementById("another-input"));
myVar.set(myVar2.get());

一个不需要getter/setter的更好的方法:

var variable = function(element){

                return function () {
                    if(arguments.length > 0)                        
                        element.value = arguments[0];                                           

                    else return element.value;                                                  
                }

        }

使用方法:

var v1 = new variable(document.getElementById("an-input"));
v1(10); // sets value to 20.
console.log(v1()); // reads value.

在这个链接“Easy Two-Way Data Binding in JavaScript”中有一个非常简单的双向数据绑定实现。

之前的链接以及来自knockoutjs, backbone.js和agility.js的想法,导致了这个轻量级和快速的MVVM框架,ModelView.js基于jQuery,它很好地使用jQuery,我是这个框架的谦虚(或者不那么谦虚)的作者。

复制下面的示例代码(来自博客文章链接):

DataBinder的示例代码

function DataBinder( object_id ) {
  // Use a jQuery object as simple PubSub
  var pubSub = jQuery({});

  // We expect a `data` element specifying the binding
  // in the form: data-bind-<object_id>="<property_name>"
  var data_attr = "bind-" + object_id,
      message = object_id + ":change";

  // Listen to change events on elements with the data-binding attribute and proxy
  // them to the PubSub, so that the change is "broadcasted" to all connected objects
  jQuery( document ).on( "change", "[data-" + data_attr + "]", function( evt ) {
    var $input = jQuery( this );

    pubSub.trigger( message, [ $input.data( data_attr ), $input.val() ] );
  });

  // PubSub propagates changes to all bound elements, setting value of
  // input tags or HTML content of other tags
  pubSub.on( message, function( evt, prop_name, new_val ) {
    jQuery( "[data-" + data_attr + "=" + prop_name + "]" ).each( function() {
      var $bound = jQuery( this );

      if ( $bound.is("input, textarea, select") ) {
        $bound.val( new_val );
      } else {
        $bound.html( new_val );
      }
    });
  });

  return pubSub;
}

对于JavaScript对象而言,是一个最小的实现 本实验的用户模型如下:

function User( uid ) {
  var binder = new DataBinder( uid ),

      user = {
        attributes: {},

        // The attribute setter publish changes using the DataBinder PubSub
        set: function( attr_name, val ) {
          this.attributes[ attr_name ] = val;
          binder.trigger( uid + ":change", [ attr_name, val, this ] );
        },

        get: function( attr_name ) {
          return this.attributes[ attr_name ];
        },

        _binder: binder
      };

  // Subscribe to the PubSub
  binder.on( uid + ":change", function( evt, attr_name, new_val, initiator ) {
    if ( initiator !== user ) {
      user.set( attr_name, new_val );
    }
  });

  return user;
}

现在,每当我们想要将一个模型的属性绑定到一个UI块时,我们 只需要设置相应的数据属性 HTML元素:

// javascript
var user = new User( 123 );
user.set( "name", "Wolfgang" );

<!-- html -->
<input type="number" data-bind-123="name" />

昨天,我开始编写自己的绑定数据的方法。

玩它很有趣。

我认为它很漂亮,很有用。至少在我使用firefox和chrome进行的测试中,Edge也能正常工作。不确定其他人,但如果他们支持代理,我认为它会起作用。

https://jsfiddle.net/2ozoovne/1/

<H1>Bind Context 1</H1>
<input id='a' data-bind='data.test' placeholder='Button Text' />
<input id='b' data-bind='data.test' placeholder='Button Text' />
<input type=button id='c' data-bind='data.test' />
<H1>Bind Context 2</H1>
<input id='d' data-bind='data.otherTest' placeholder='input bind' />
<input id='e' data-bind='data.otherTest' placeholder='input bind' />
<input id='f' data-bind='data.test' placeholder='button 2 text - same var name, other context' />
<input type=button id='g' data-bind='data.test' value='click here!' />
<H1>No bind data</H1>
<input id='h' placeholder='not bound' />
<input id='i' placeholder='not bound'/>
<input type=button id='j' />

代码如下:

(function(){
    if ( ! ( 'SmartBind' in window ) ) { // never run more than once
        // This hack sets a "proxy" property for HTMLInputElement.value set property
        var nativeHTMLInputElementValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
        var newDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
        newDescriptor.set=function( value ){
            if ( 'settingDomBind' in this )
                return;
            var hasDataBind=this.hasAttribute('data-bind');
            if ( hasDataBind ) {
                this.settingDomBind=true;
                var dataBind=this.getAttribute('data-bind');
                if ( ! this.hasAttribute('data-bind-context-id') ) {
                    console.error("Impossible to recover data-bind-context-id attribute", this, dataBind );
                } else {
                    var bindContextId=this.getAttribute('data-bind-context-id');
                    if ( bindContextId in SmartBind.contexts ) {
                        var bindContext=SmartBind.contexts[bindContextId];
                        var dataTarget=SmartBind.getDataTarget(bindContext, dataBind);
                        SmartBind.setDataValue( dataTarget, value);
                    } else {
                        console.error( "Invalid data-bind-context-id attribute", this, dataBind, bindContextId );
                    }
                }
                delete this.settingDomBind;
            }
            nativeHTMLInputElementValue.set.bind(this)( value );
        }
        Object.defineProperty(HTMLInputElement.prototype, 'value', newDescriptor);

    var uid= function(){
           return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
               var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
               return v.toString(16);
          });
   }

        // SmartBind Functions
        window.SmartBind={};
        SmartBind.BindContext=function(){
            var _data={};
            var ctx = {
                "id" : uid()    /* Data Bind Context Id */
                , "_data": _data        /* Real data object */
                , "mapDom": {}          /* DOM Mapped objects */
                , "mapDataTarget": {}       /* Data Mapped objects */
            }
            SmartBind.contexts[ctx.id]=ctx;
            ctx.data=new Proxy( _data, SmartBind.getProxyHandler(ctx, "data"))  /* Proxy object to _data */
            return ctx;
        }

        SmartBind.getDataTarget=function(bindContext, bindPath){
            var bindedObject=
                { bindContext: bindContext
                , bindPath: bindPath 
                };
            var dataObj=bindContext;
            var dataObjLevels=bindPath.split('.');
            for( var i=0; i<dataObjLevels.length; i++ ) {
                if ( i == dataObjLevels.length-1 ) { // last level, set value
                    bindedObject={ target: dataObj
                    , item: dataObjLevels[i]
                    }
                } else {    // digg in
                    if ( ! ( dataObjLevels[i] in dataObj ) ) {
                        console.warn("Impossible to get data target object to map bind.", bindPath, bindContext);
                        break;
                    }
                    dataObj=dataObj[dataObjLevels[i]];
                }
            }
            return bindedObject ;
        }

        SmartBind.contexts={};
        SmartBind.add=function(bindContext, domObj){
            if ( typeof domObj == "undefined" ){
                console.error("No DOM Object argument given ", bindContext);
                return;
            }
            if ( ! domObj.hasAttribute('data-bind') ) {
                console.warn("Object has no data-bind attribute", domObj);
                return;
            }
            domObj.setAttribute("data-bind-context-id", bindContext.id);
            var bindPath=domObj.getAttribute('data-bind');
            if ( bindPath in bindContext.mapDom ) {
                bindContext.mapDom[bindPath][bindContext.mapDom[bindPath].length]=domObj;
            } else {
                bindContext.mapDom[bindPath]=[domObj];
            }
            var bindTarget=SmartBind.getDataTarget(bindContext, bindPath);
            bindContext.mapDataTarget[bindPath]=bindTarget;
            domObj.addEventListener('input', function(){ SmartBind.setDataValue(bindTarget,this.value); } );
            domObj.addEventListener('change', function(){ SmartBind.setDataValue(bindTarget, this.value); } );
        }

        SmartBind.setDataValue=function(bindTarget,value){
            if ( ! ( 'target' in bindTarget ) ) {
                var lBindTarget=SmartBind.getDataTarget(bindTarget.bindContext, bindTarget.bindPath);
                if ( 'target' in lBindTarget ) {
                    bindTarget.target=lBindTarget.target;
                    bindTarget.item=lBindTarget.item;
                } else {
                    console.warn("Still can't recover the object to bind", bindTarget.bindPath );
                }
            }
            if ( ( 'target' in bindTarget ) ) {
                bindTarget.target[bindTarget.item]=value;
            }
        }
        SmartBind.getDataValue=function(bindTarget){
            if ( ! ( 'target' in bindTarget ) ) {
                var lBindTarget=SmartBind.getDataTarget(bindTarget.bindContext, bindTarget.bindPath);
                if ( 'target' in lBindTarget ) {
                    bindTarget.target=lBindTarget.target;
                    bindTarget.item=lBindTarget.item;
                } else {
                    console.warn("Still can't recover the object to bind", bindTarget.bindPath );
                }
            }
            if ( ( 'target' in bindTarget ) ) {
                return bindTarget.target[bindTarget.item];
            }
        }
        SmartBind.getProxyHandler=function(bindContext, bindPath){
            return  {
                get: function(target, name){
                    if ( name == '__isProxy' )
                        return true;
                    // just get the value
                    // console.debug("proxy get", bindPath, name, target[name]);
                    return target[name];
                }
                ,
                set: function(target, name, value){
                    target[name]=value;
                    bindContext.mapDataTarget[bindPath+"."+name]=value;
                    SmartBind.processBindToDom(bindContext, bindPath+"."+name);
                    // console.debug("proxy set", bindPath, name, target[name], value );
                    // and set all related objects with this target.name
                    if ( value instanceof Object) {
                        if ( !( name in target) || ! ( target[name].__isProxy ) ){
                            target[name]=new Proxy(value, SmartBind.getProxyHandler(bindContext, bindPath+'.'+name));
                        }
                        // run all tree to set proxies when necessary
                        var objKeys=Object.keys(value);
                        // console.debug("...objkeys",objKeys);
                        for ( var i=0; i<objKeys.length; i++ ) {
                            bindContext.mapDataTarget[bindPath+"."+name+"."+objKeys[i]]=target[name][objKeys[i]];
                            if ( typeof value[objKeys[i]] == 'undefined' || value[objKeys[i]] == null || ! ( value[objKeys[i]] instanceof Object ) || value[objKeys[i]].__isProxy )
                                continue;
                            target[name][objKeys[i]]=new Proxy( value[objKeys[i]], SmartBind.getProxyHandler(bindContext, bindPath+'.'+name+"."+objKeys[i]));
                        }
                        // TODO it can be faster than run all items
                        var bindKeys=Object.keys(bindContext.mapDom);
                        for ( var i=0; i<bindKeys.length; i++ ) {
                            // console.log("test...", bindKeys[i], " for ", bindPath+"."+name);
                            if ( bindKeys[i].startsWith(bindPath+"."+name) ) {
                                // console.log("its ok, lets update dom...", bindKeys[i]);
                                SmartBind.processBindToDom( bindContext, bindKeys[i] );
                            }
                        }
                    }
                    return true;
                }
            };
        }
        SmartBind.processBindToDom=function(bindContext, bindPath) {
            var domList=bindContext.mapDom[bindPath];
            if ( typeof domList != 'undefined' ) {
                try {
                    for ( var i=0; i < domList.length ; i++){
                        var dataTarget=SmartBind.getDataTarget(bindContext, bindPath);
                        if ( 'target' in dataTarget )
                            domList[i].value=dataTarget.target[dataTarget.item];
                        else
                            console.warn("Could not get data target", bindContext, bindPath);
                    }
                } catch (e){
                    console.warn("bind fail", bindPath, bindContext, e);
                }
            }
        }
    }
})();

然后,设置,只需:

var bindContext=SmartBind.BindContext();
SmartBind.add(bindContext, document.getElementById('a'));
SmartBind.add(bindContext, document.getElementById('b'));
SmartBind.add(bindContext, document.getElementById('c'));

var bindContext2=SmartBind.BindContext();
SmartBind.add(bindContext2, document.getElementById('d'));
SmartBind.add(bindContext2, document.getElementById('e'));
SmartBind.add(bindContext2, document.getElementById('f'));
SmartBind.add(bindContext2, document.getElementById('g'));

setTimeout( function() {
    document.getElementById('b').value='Via Script works too!'
}, 2000);

document.getElementById('g').addEventListener('click',function(){
bindContext2.data.test='Set by js value'
})

现在,我只添加了HTMLInputElement值绑定。

如果你知道如何改进,请告诉我。

绑定任何html输入

<input id="element-to-bind" type="text">

定义两个函数:

function bindValue(objectToBind) {
var elemToBind = document.getElementById(objectToBind.id)    
elemToBind.addEventListener("change", function() {
    objectToBind.value = this.value;
})
}

function proxify(id) { 
var handler = {
    set: function(target, key, value, receiver) {
        target[key] = value;
        document.getElementById(target.id).value = value;
        return Reflect.set(target, key, value);
    },
}
return new Proxy({id: id}, handler);
}

使用函数:

var myObject = proxify('element-to-bind')
bindValue(myObject);