Skip to main content

3 Ways to Implement Two-Way Data Binding

Free2017-02-12#JS#双向数据绑定#data binding#angular dirty checking#vue data binding#angular data binding#js数据绑定

Implementing two-way data binding in three different ways, including data model control, setter change monitoring, and dirty checking.

Foreword

From no state to manual state maintenance and DOM manipulation for view updates, then to two-way data binding, various front-end solutions have been striving to simplify the association between data and views. The current trend is to weaken the DOM, strengthen data states, and focus on "real" logic.

1. Milestones

Server-Side Rendering: Reset The Universe

There is no change. The universe is immutable.

No state, weak front-end

View control and logic functions are all provided by the server. The front-end only handles interactions like form validation and directly tells the server about user actions:

Front-end: Next button clicked. Front-end "dies".
Server: Okay, here is the next page

Logic and state are kept on the server. The front-end code is just one line: if (isValid) form.submit(); else alert('invalid')

P.S. Haven't seen SSR in a long time...

First-gen JS: Manual Re-rendering

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

Front-end maintains state, manual DOM manipulation to update views

Front-end code increased, needing to maintain state. Front-end frameworks of this era (e.g., Backbone.js, Ext JS, Dojo) aimed to separate MVC according to "best practices":

Front-end Framework: Give me data, give me templates, I'll render for you
Front-end Dev: Okay, here
Front-end Framework: The user just clicked a button. I don't know which part to re-render, do it yourself.
Front-end Dev: document.getXXX().xxx()

Frameworks help separate data and views, but subsequent state updates require manual DOM manipulation because the framework only handles the initial render and does not track state changes.

Ember.js: Data Binding

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

Manual DOM manipulation is too troublesome; find a way to simplify

The framework wants to weaken DOM manipulation and focus only on data, so it needs to know the mapping from data to view:

Front-end Framework: Give me data, give me templates, I'll render for you
Front-end Dev: Okay, here
Front-end Framework: Not this. Data must be packed in the box I give you.
Front-end Dev: Fine, here
Front-end Dev: I changed the data
Front-end Framework: Received, page content updated

The framework provides a data model to wrap the data, so all subsequent additions, deletions, and modifications must go through the framework's API. The framework knows when data changes and updates the corresponding view; the framework monitors data changes.

AngularJS: Dirty Checking

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

Using framework-provided data models is too troublesome; find a way to track state changes directly

Front-end Framework: Give me data, give me templates, I'll render for you
Front-end Dev: Okay, here
Front-end Dev: Help me monitor data.name; update the text of the h1 when it changes.
Front-end Framework: Okay
Front-end Framework: It changed. I know what to do. It was xxx before, now it's aaa, so change the h1 content to aaa

The framework records the previous value. When data might have changed (via DOM operations or manual notification), it compares the latest value with the previous one. If they differ, it updates the view with the latest value, ensuring the view state matches the data state.

React: Virtual DOM

Dirty checking is too tedious (checking everything whenever anything moves); find another way

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

Don't like troublesome data models, think dirty checking is silly; see if we can avoid monitoring data changes:

Front-end Framework: Give me data, give me templates, I'll render for you
Front-end Dev: Okay, here
Front-end Dev: I changed the data; you figure out how to update the view
Front-end Framework: Okay, notify downwards from here. Descendants will figure out how to update themselves. Simple ones (attribute updates) do it themselves; tell me about the complicated ones (structural updates).
Front-end Framework: Oh, delete a span, insert 2 lis, done

The framework doesn't actively track when data changes; state changes need to be manually notified. The framework looks down and knows exactly what to do.

Vue

Isn't defining a setter the most straightforward way to monitor data changes?

However, defining a setter cannot monitor all data changes, but it satisfies most scenarios. For the few it can't, apply some restrictions:

Front-end Framework: Give me data, give me templates, I'll render for you
Front-end Dev: Okay, here
Front-end Dev: data.value = 'new value'
Front-end Framework: setter says the value changed; I'll update the view
Front-end Dev: data.newKey = 'new key value'
Front-end Framework: zzZ
Front-end Dev: $set('newKey', 'new key value')
Front-end Framework: New property added; I'll update the view

The framework tracks data changes and updates the view; a few scenarios require manual notification of state changes.

2. Setter Change Monitoring

Vue uses this method. The basic idea is as follows:

  1. Data to View: Iterate through data to define setters, and update view within the setter.
  2. View to Data: Monitor elements like input that require two-way binding, listen for relevant events, and update data in the handler.

The handling of View to Data is simple, with no alternatives or controversy. Data to View needs to consider scenarios where setter fails:

  • Adding new properties to an object (data.newKey = 'value') cannot be detected by the setter.

  • Deleting existing properties with delete cannot be detected by the setter.

  • Array changes cannot be detected.

The first problem cannot be solved before ES6 Proxy is available (Object.observe() and Array.observe() have been deprecated). Therefore, an additional API must be provided to support manual notification of additions, for example:

var data = {a: 1};
var vm = new Vue({
  data: data
});
// Notify the framework of a new property addition via an additional API
vm.$set('b', 2);

The second problem is minor. Scenarios requiring delete are rare; assigning undefined and adding a bit of extra logic can avoid delete.

The third problem is more serious and difficult to solve. Vue compensates for this by hijacking native Array methods (injecting into all array methods that change array content):

/**
 * 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 () {
    //...
    // Inject monitoring part
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
  })
})

Taken from https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js

This captures cases where array content changes via array methods, but two problems remain:

  • Data changes caused by length modification like arr.length=3 cannot be detected.

  • Changes from modifying values via index like arr[0] = 1 cannot be detected.

Define a setter for length like with objects? No, the length of an array cannot be hijacked via 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

Modifying array content via length can be replaced by other methods (like splice), so it's not critical; we'll leave it for now.

Modifying array elements via index is a very common method and must be handled. Therefore, additional APIs must be added to support modifying specific array elements:

// Older versions added $set() and $remove() to the prototype of monitored arrays
// Now they seem gone, replaced by the global Vue.set()
Vue.set(vm.arr, 0, 'setted')

Thus, data change monitoring is basically complete.

Implementation Attempt

Arrays are slightly more troublesome, so let's ignore them for now. Here, we'll implement two-way binding between input, div, and simple data objects.

The key setter change monitoring part:

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);

Update the view inside the setter to complete the data-to-view binding.

Then monitor the input event of the input element to complete the view-to-data binding:

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

Of course, real-world scenarios must consider various view changes like input, checkbox, and select that affect state.

Directly modifying the data will be caught by the setter, which then updates the view:

// Manually change variable value
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

Complete Demo URL: http://ayqy.net/temp/data-binding/setter.html

3. Providing a Data Model

Ember.js uses this method.

The framework provides a set of data models to wrap the actual data. Subsequent updates must use the data model API, so the framework captures all data changes, thus completing the data-to-view binding. This is an older method and is quite cumbersome to use.

Implementation Attempt

Similarly, we'll implement two-way binding between input, div, and strings here.

First, provide the data model and update the view within it:

// Providing a data model
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) {
                    // Avoid input assignment jumping cursor to end
                    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;
    }
};

Providing a data model is actually equivalent to defining a setter. Similarly, to monitor changes, providing a data model is foolproof: supported data operations can be monitored, and unsupported ones cannot cause changes, making it quite simple to handle.

Then establish the connection:

// 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);
});

Subsequent data updates must go through the data model API:

// Manually change variable value
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

Complete Demo URL: http://ayqy.net/temp/data-binding/model.html

4. Dirty Checking

Angular uses this method.

It doesn't monitor data changes but instead checks if data has changed from time to time. If it has, it updates the corresponding view.

So the questions are:

  • When to check?

    Check whenever a data change might have occurred, such as after DOM operations or interaction events. When inconsistency is suspected, check to see if anything changed.

  • How to know which part changed?

    It doesn't know, so it must check all data bound to the view. Of course, "all" doesn't mean the entire page, but rather component-level ($scope). So, while performance isn't great, it's fine for most scenarios.

If data is manually modified and an immediate view update is desired, the dirty checking mechanism might not run until some point in the future. In such cases, dirty checking must be triggered manually, which is less convenient.

Implementation Attempt

Let's build a simple set in Angular style:

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

Scope.prototype.$watch = function(watchExp, listener) {
    this.$$watchers.push({
        watchExp: watchExp,
        listener: listener || function() {}
        // Subsequent dirty checking will add a last property to cache oldValue
    });
};

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

    do {
        dirty = false;

        // Iterate through watchers to check if last has become dirty
        for(var i = 0; i < this.$$watchers.length; i++) {
            // Retrieve the value again using the watchExp method
            // It doesn't fetch directly by key, which is more flexible (a new value can be manually wrapped in watchExp regardless of who owns the value)
            var newValue = this.$$watchers[i].watchExp(),
                oldValue = this.$$watchers[i].last;

            if(oldValue !== newValue) {
                // The recorded last value is dirty; trigger the callback
                // On the first digest, last is undefined, so the listener will execute at least once
                this.$$watchers[i].listener(newValue, oldValue);
                // Instruct the outer loop to check again later to see if executing the listener caused other changes
                //! As long as dirt is found, it must be checked again to ensure digest is complete and last matches the data
                dirty = true;
                // Update cached value
                this.$$watchers[i].last = newValue;
            }
        }
    } while (dirty);
};

watch() records the value retrieval method and the callback function. The digest() process iterates through watchers, retrieves values again, and checks if the cached value is dirty.

If any "dirt" is found in a single pass, it must be checked again until the data and the cached value are completely consistent. This could lead to infinite loops (when two data changes are interrelated), so at least a maximum consecutive check limit (TTL) should be added to avoid infinite loops. I'll omit it here for simplicity.

P.S. For detailed Angular implementation analysis, see the references.

Implement data-to-view binding using the dirty checking mechanism:

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);

Similarly, implement the view-to-data binding:

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

To update the view immediately after modifying data privately, manual dirty checking must be executed:

// Manually change variable value
$('#btn').onclick = function() {
    $scope.value = 'updated value ' + Date.now();
    $scope.$digest();
};

Complete Demo URL: http://ayqy.net/temp/data-binding/dirty-checking.html

5. Virtual DOM

The three data binding methods have been introduced above, because Virtual DOM cannot be considered another way to implement two-way data binding (although Virtual DOM achieves one-way data binding).

React uses this method. Consider the update of the Virtual DOM tree:

  • Attribute updates are handled by the component itself.

  • For structural updates, the subtree (Virtual DOM) is re-"rendered" to find the minimum changes, and DOM operations are batched to patch the real DOM tree.

Purely from a data binding perspective, React Virtual DOM has no data binding because setState() does not maintain the previous state (state discarding) and does not track changes, so it can't be called binding.

From a data update mechanism perspective, React is similar to the data model approach (updates must go through state).

P.S. Structural updates can also be described as creating a subtree and diff-ing it with the existing subtree to record the minimum changes and batching DOM operations to update the real DOM. But in fact, this happens during a recursive downward check, updating the Virtual DOM tree while recording the minimum changes. Therefore, I emphasized the downward subtree check above rather than the diff between two trees.

Without two-way data binding, how is the input scenario implemented?

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')
);

Manually notify data changes through the API provided by the framework, which is very similar to the way the DOM is manipulated:

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>';

6. Update Efficiency Optimization

The initial render is straightforward. Subsequent updates all face the problem of how to map data updates to real DOM updates.

In React, setState() doesn't care if the data is the same as before; whether it changed or not, the previous state is discarded as long as a state is passed in. This means even if the original data object is passed into setState() unchanged, the entire subtree must be checked downward to conclude that the data hasn't changed and the view doesn't need updating. Due to the state discarding mechanism, details of data changes aren't tracked, so it's impossible to immediately determine if the state has changed even if the same data is provided.

Vue faces a similar issue, but the situation is better. After the setter detects a change, it doesn't know the impact of this state update on the subtree. However, the obvious advantage of maintaining data states and tracking changes is that the view associated with each piece of data is known. That is, the list of real nodes related to specific data is known, so only the real nodes dependent on that data need to be updated directly (of course, optimizations like full replacement versus maintenance update during "violent" state changes still need to be considered). Therefore, the Vue watcher has a dependency collection mechanism to speed up the downward check (non-dependencies are not checked).

Angular needs to check the $$watchers array of the current $scope to determine if the state has changed, resulting in many unnecessary checks. A simple value property update, which might only correspond to one text node, still requires checking all data-to-view bindings under the $scope. Because it is also a state discarding mechanism, dirty checking only cares about values and not data structures. When data is manually modified and dirty checking is notified of a change, it can only retrieve and compare values one by one because it doesn't know the mapping between the modified data and the values it needs to retrieve, so it cannot narrow the scope.

For React, there's a simple optimization plan to skip checks for unchanged data: use immutable data structures, such as Immutable.js.

This way, properties of the state don't need to be checked one by one to determine if no change occurred; simply use equals() first, and if they are equal, there's no need to go further.

Om does exactly this:

I know exactly what didn't change.

Using immutable data structures allows for quickly excluding and cutting out what hasn't changed, yielding the parts that have changed and improving diff efficiency.

P.S. Of course, using immutable data structures for dirty checking is pointless, as it still has to iterate and retrieve values to know if they've changed; dirty checking simply doesn't care about data structures. If setter monitoring used immutable data structures, it would be too inflexible, and the monitoring granularity wouldn't be fine enough, losing its advantage. As for the data model approach, it goes without saying: if data models are immutable, there's no change to monitor.

7. References

These references have high reference value; it is recommended to read them as if they were part of the main text.

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment