2

Given json data provided by an angular service:

{
  "editableData": {
    "test": {
      "value": "Never gonna give you up"
    }
  },
  "hiddenData": {
    "test": {
      "value": "Never gonna let you down"
    }
  }
}

... I want to let the user modify editableData.test.value, but continuously synchronise that value across to private hiddenData.test.value. The part that has me stumped is the best way to ensure this update is always triggered when the first value is changed, and that it happens as soon as possible.

Real-world complications

  1. Must work in IE8+ (Angular 1.2.x; es5-shim allowed), and not break or overcomplicate two-way binding.

  2. The 'public' property is simultaneously used by multiple controllers, directives and intermediary services, so as per DRY it would be better to handle this in the core service than in every other place it's used.

  3. It's undesirable/unmaintainable to implement a solution that would involve declaring rules for developers like 'whenever this property gets changed, you must remember to call xyz() afterwards to refresh any mirroring'.

  4. The actual data structure is much larger, and may have multiple properties that must be mirrored. Any solution should be fairly easy to scale up.

Possible Solution 1: $rootScope.$watch()

Within the service, I could use $rootScope.$watch(funcThatRunsOnEveryDigest) to fire on every $digest and continually copy values across.

However, I'm uneasy because:

  1. I know it can lead to issues when you start giving $rootScope to services... I think this may be a situation that warrants it, but it feels like a sin.

  2. This would run on every single $digest, regardless of whether the properties have changed. But this would be the case with any watcher, right (that is, the watcher expression/function is always run)? And better than having dozens of watchers, one per mirrored property, each dirty checking?

  3. Am I going to run into issues if editableData.test.value isn't currently exposed on $scope somewhere, but it gets modified behind-the-scenes by code running in response to some other user action, or resolution of an async op?

Possible Solution 2: Link by reference

Simply linking the properties by reference:

//linked at the parent obj containing the .value property
_data.hiddenData.test = _data.editableData.test;

There are still some further implications to consider, including how easily this reference may be broken, and that it's kind of 'sneaky' and seems like it could surprise maintenance devs.

Better answers or insights into implications I haven't considered greatly appreciated!

http://plnkr.co/edit/mOhFBFfKfqDiHBFEgEpH?p=preview

(function() {
  "use strict";
  
  angular.module("myApp", ["myServices"])
  .controller("Controller1", ["$scope", "dataServiceFacade1", Controller1])
  .controller("Controller2", ["$scope", "dataServiceFacade2", Controller2])
  .controller("Controller3", ["$scope", "dataServiceCore", Controller3]);
  
  angular.module("myServices", [])
  .service("dataServiceCore", ["$rootScope", DataServiceCore])
  .service("dataServiceFacade1", ["dataServiceCore", DataServiceFacade1])
  .service("dataServiceFacade2", ["dataServiceCore", DataServiceFacade2]);
  
  /* myApp controllers */
  function Controller1($scope, dataServiceFacade1) {
    $scope.data = dataServiceFacade1.data; //using facade1 which returns editableData.test.value as test1.value
  }
  
  function Controller2($scope, dataServiceFacade2) {
    $scope.data = dataServiceFacade2.data; //using facade2 which returns editableData.test.value as test2.value
  }
  
  function Controller3($scope, dataServiceCore) {
    $scope.data = dataServiceCore.data; //no facade, raw data straight from the core
    $scope.isWatching = dataServiceCore.mirrorByRootScopeWatch; // for toggling the $rootScope.$watch on and off
    $scope.isReferencing = dataServiceCore.mirrorByRef; // for toggling ref on and off
    $scope.reset = dataServiceCore.reset;
  }
  
  /* myServices services */
  function DataServiceCore($rootScope) {
    
    var _data,
    _isWatching,
    _watcherDereg,
    _isReferencing;
    
    _init();
    
    //##################################################
    //# Mirroring by updating from within the service, #
    //# listening to every digest...                   #
    //##################################################
    function _watcherFireOnEveryDigest() {
      _data.hiddenData.test.value = _data.editableData.test.value; //mirroring the value
    }
    
    //_isWatching flag getter/setter
    function _mirrorByRootScopeWatch(value) {
      if(typeof value !== "undefined") {
        _isWatching = value;
        
        if(_isWatching) {
          _mirrorByRef(false);
          _watcherDereg = $rootScope.$watch(_watcherFireOnEveryDigest); //no listener function
        } else if(typeof _watcherDereg === "function") {
          _watcherDereg();
          _watcherDereg = null;
        }
      }
      
      return _isWatching;
    }
    
    function _mirrorByRef(value) {
      if(typeof value !== "undefined") {
        _isReferencing = value;
        
        if(_isReferencing) {
          _mirrorByRootScopeWatch(false);
          //##################################################
          //# Mirroring by creating reference from one prop  #
          //# to the other...                                #
          //##################################################
          _data.hiddenData.test = _data.editableData.test; //linking by ref
        } else {
          _data.hiddenData.test = JSON.parse(JSON.stringify(_data.hiddenData.test)); //set to a de-ref'd copy of itself
        }
      }
      
      return _isReferencing;
    }
    
    function _init() {
      if(_data) {
        //if _data already exists, merge (deep copy / recursive extend) so we update without breaking existing ref's
        merge(_data, _getData());
      } else {
        _data =_getData();
      }
      _mirrorByRootScopeWatch(false);
      _mirrorByRef(false);
    }
    
    //return a clone of the original data
    function _getData() {
      return JSON.parse(JSON.stringify({
        "editableData": {
          "test": {
            "value": "Never gonna give you up"
          }
        },
        "hiddenData": {
          "test": {
            "value": "Never gonna let you down"
          }
        }
      }));
    }
    
    //merge function adapted from angular.merge (angular 1.4+) as per http://stackoverflow.com/a/29003438/446030
    function merge(dst){
      var slice = [].slice;
      var isArray = Array.isArray;
      function baseExtend(dst, objs, deep) {
        for (var i = 0, ii = objs.length; i < ii; ++i) {
          var obj = objs[i];
          if (!angular.isObject(obj) && !angular.isFunction(obj)) continue;
          var keys = Object.keys(obj);
          for (var j = 0, jj = keys.length; j < jj; j++) {
            var key = keys[j];
            var src = obj[key];
            if (deep && angular.isObject(src)) {
              if (!angular.isObject(dst[key])) dst[key] = isArray(src) ? [] : {};
              baseExtend(dst[key], [src], true);
            } else {
              dst[key] = src;
            }
          }
        }
    
        return dst;
      }
      return baseExtend(dst, slice.call(arguments, 1), true);
    }
    
    return {
      data: _data,
      mirrorByRootScopeWatch: _mirrorByRootScopeWatch,
      mirrorByRef: _mirrorByRef,
      reset: _init
    };
  }
  
  function DataServiceFacade1(dataServiceCore) {
    var _data = {
      "test1": dataServiceCore.data.editableData.test
    };
    
    return {
      data: _data
    };
  }
  
  function DataServiceFacade2(dataServiceCore) {
    var _data = {
      "test2": dataServiceCore.data.editableData.test
    };
    
    return {
      data: _data
    };
  }
  
})();
<!DOCTYPE html>
<html>

  <head>
    <script data-require="angular.js@*" data-semver="1.2.28" src="https://code.angularjs.org/1.2.28/angular.js"></script>
    <style type="text/css">
      body {font: 0.9em Arial, Verdana, sans-serif;}
      div {margin-bottom: 4px;}
      label {margin-right: 8px;}
      p {font-size: 0.9em; color: #999;}
      code {color:#000; background-color: #eee}
      pre code {display:block;}
    </style>
    <script src="script.js"></script>
  </head>

  <body>
    <div ng-app="myApp">
      
      <div ng-controller="Controller1">
        <h4>Controller1</h4>
        <p>This value is linked to <code>editableData.test.value</code> via
        reference in its facade service.</p>
        <label for="test1">test1.value</label>
        <input ng-model="data.test1.value" id="test1" />
        <pre><code>{{data|json}}</code></pre>
      </div>
      
      <div ng-controller="Controller2">
        <h4>Controller2</h4>
        <p>This value is <em>also</em> linked to
        <code>editableData.test.value</code> via reference in its facade
        service.</p>
        <label for="test2">test2.value</label>
        <input ng-model="data.test2.value" id="test2" />
        <pre><code>{{data|json}}</code></pre>
      </div>
      
      <div ng-controller="Controller3">
        <h4>Core Data</h4>
        <p>'Mirroring' the value of <code>editableData.test.value</code> to
        <code>hiddenData.test.value</code> by listening for every
        <code>$rootScope.$digest</code> from within the service, and copying
        between them.</p>
        <p>Enable/Disable mirroring with the button below, and type in the
        input fields above.</p>
        <button ng-click="isWatching(!isWatching());"><strong>
          {{isWatching() ? "Disable" : "Enable"}}</strong> mirroring with
          <code>$rootScope.$watch</code></button>
        <button ng-click="isReferencing(!isReferencing());"><strong>
          {{isReferencing() ? "Disable" : "Enable"}}</strong> mirroring by ref
          </button>
        <button ng-click="reset()">Reset</button>
        <pre><code>{{data|json}}</code></pre>
      </div>
      
    </div>
  </body>

</html>

Update: Based on accepted answer, have made some further modifications to a fork of the Plnkr to play with encapsulating to a separate service. More complicated than necessary, really, so that I can still test out By Ref vs By $rootScope.$watch():

http://plnkr.co/edit/agdBWg?p=preview

JcT
  • 3,539
  • 1
  • 24
  • 34

1 Answers1

1

EDIT: It seems I misunderstood the question, since it was about how to watch for changes in the data object, you could add a separate service/controller whose ONLY job is to watch for the change. For me that sounds enough of a separation of concerns to make it quite ok. Here is an example using a DataWatcher controller.

function DataWatcher($scope, dataServiceCore) {
  function _watcherHasChanged() {
    return dataServiceCore.data.editableData;
  }
  function _watcherFireOnEveryChange() {
    dataServiceCore.data.hiddenData.test.value = dataServiceCore.data.editableData.test.value; //mirroring the value
  }
  $scope.$watch(_watcherHasChanged, _watcherFireOnEveryChange, true);
}

http://plnkr.co/edit/PWPUPyEXGN5hcc9JANBo?p=preview

OLD ANSWER:

According to What is the most efficient way to deep clone an object in JavaScript? JSON.parse(JSON.stringify(obj)) is the most efficient way to clone an object. Which is essentially what you're trying to do here.

So something like

myObject.hiddenData = JSON.parse(JSON.stringify(myObject.editableData);

executed whenever editableData is changed in myObject might be what you're looking for.

Community
  • 1
  • 1
Jan
  • 5,688
  • 3
  • 27
  • 44
  • Hi Jan - thanks I've found that method of cloning to be very helpful. The part that has had me stumped is the best way to update, as you say, 'whenever `editableData` is changed in `myObject`'. I need to ensure that update is always triggered, as quickly as possible - I'll update my question to try and clarify this, thanks. – JcT Jun 19 '15 at 01:00
  • Yes, I actually took the time to read the whole wall of text and saw that's what you meant. Don't have an immediate answer for that one though. – Jan Jun 19 '15 at 01:05
  • If you're gonna link the properties by reference though, you might just as well almost skip the whole link and just reference the original object, since in effect that's what you're doing anyways. So I don't see that as a solution at all. And reading that was what made me originally answer like I did. – Jan Jun 19 '15 at 01:07
  • I wish I could! Unfortunately I need to maintain these several objects separately - they're all part of a structure returned by an API I can't control, and I need to occasionally send deltas back to the server (using a json diff library). There are occasions where one public item must be edited in the GUI, but then cascade changes back to these other hidden objects. At the end of it all, even if the user has only modified one property, I need to tell the server multiple properties have all been updated. – JcT Jun 19 '15 at 01:17
  • 1
    Couldn't you just add a separate service, whose only job is listening on the `data` object and updating the hiddendata on any change? Would that be enough of a separation of concerns for you to feel comfortable injecting the scope into just that specific service, seeing as that would be its one and only job? – Jan Jun 19 '15 at 01:41
  • I like that - it seems like less of a sneaky hack than references, and is much cleaner than jamming the `$rootScope` dependency into the original data service(s). I can just provide this new service instead and give it a simple interface for registering properties to mirror. Hopefully relying on `$digest` to trigger updates is safe enough. Thank you! – JcT Jun 19 '15 at 01:50
  • 1
    I was fiddling around a bit with your code, added an example of using a controller but it could be a service or whatevs. :) – Jan Jun 19 '15 at 02:18
  • @JcT Ok, you probably already solved this but it was bugging me that this was run on every digest. So I went and updated my solution to watch only for actual changes. I don't know which is actually more efficient but there you go. – Jan Jun 21 '15 at 23:04
  • Thanks for the continued investigation! My reasoning for using the watch expression(/function) was that since the watcher is defined on rootScope, it's going to be evaluated every digest anyway. I may have a dozen or more of these - rather than dirty check every property on every digest and then maybe call individual watch listeners if some have changed, I thought it might be more efficient to have the watcher expression just do the copy, have no return and/or listener and trigger no further digests. Would be interesting to perf test at some point... – JcT Jun 22 '15 at 06:15