Angular async calls

2019-03-04 20:31发布

问题:

I have a bizarre issue happening in my Angular app and I think the async'ness of Angular is the cause but I'm not sure how I can fix it.

On .run I have the following:

app.run(function ($user, $soundcloud) {
    $user.set();
    $soundcloud.set();
});

and then the two services...

app.service('$user', ['$http',  function ($http) {    
    var currentUser;    
    this.set = function () {
        $http.get('/users/active/profile')
            .success(function (obj) {
                currentUser = obj;
            })
            .error(function () {
                console.log('ERR');
            })
    }    
    this.fetch = function () {
        return currentUser;
    }

}]);    
app.service('$soundcloud', ['$http', function ($http) {    
    var sc;    
    this.set = function () {
        $http.get('/users/active/services/soundcloud')
            .success(function (obj) {
                sc = obj;
            })
            .error(function () {
                console.log('Error fetching soundcloud feed')
            })
    }    
    this.fetch = function () {
        return sc;
    }    
}]);

The issue I'm having is that when I go to /profile and reload the page, the data returned from $soundcloud.fetch() isn't available unless I wrap $soundcloud.fetch() inside a setTimeout() function which obviously isn't ideal so I'm wondering where I should place $soundcloud.set() to also make that data available immediately on page load.

This is my controller currently:

app.controller('profileController', ['$scope', '$user', '$soundcloud', function ($scope, $user, $soundcloud) {

    $scope.activeUser = $user.fetch();
    $scope.activeSoundcloud = $soundcloud.fetch();

    console.log($user.fetch());
    setTimeout(function () {
        console.log($soundcloud.fetch());
    },100);
}]);

If I take $soundcloud.fetch() outside of the setTimeout then I get undefined as the result. Where should $soundcloud.set/fetch be called in order to make both sets of data (user and soundcloud) immediately available?

回答1:

PLUNKER DEMO

One way to solve this problem is to fetch all the necessary data and then manually bootstrap the application.

To do this, create bootstrapper.js. You can access the built-in angular services without bootstrapping a module by angular.injector(['ng']).invoke() which accepts a function with dependencies that can be injected. The urlMap variable is simply a key value store for the settings together with each of its respective urls. You loop this urlMap via angular.forEach() and then get all settings one by one and store each settings in a settings variable along with each of its respective keys. Store each $http promise request in promises array to resolve it with $q.all(). When all promises have been resolved, call bootstrap() function which adds a Settings value in app.settings module and then manually bootstraps the application.

bootstrapper.js

angular.injector(['ng']).invoke(function($http, $q) {

  var urlMap = {
    $user: '/users/active/profile',
    $soundcloud: '/users/active/services/soundcloud'
  };

  var settings = {};

  var promises = [];

  var appConfig = angular.module('app.settings', []);

  angular.forEach(urlMap, function(url, key) {
    promises.push($http.get(url).success(function(data) {
      settings[key] = data;
    }));
  });

  $q.all(promises).then(function() {
    bootstrap(settings);
  }).catch(function() {
    bootstrap();
  });

  function bootstrap(settings) {
    appConfig.value('Settings', settings);

    angular.element(document).ready(function() {
      angular.bootstrap(document, ['app', 'app.settings']);
    });
  }

});

app.js

angular.module('app', [])

  .run(function(Settings) {
    if(Settings === null) {
      // go to login page or
      // a page that will display an
      // error if any of the requested
      // settings failed
    }
  })

  .controller('ProfileController', function($scope, Settings) {
    console.log(Settings);
  });


回答2:

The result from $soundcloud.set() overrides the value from sc.

If you tried to bind the value from $soundcloud.fetch to the scope before the $soundcloud.set() is returned it binds undefined to the scope, since the var sc is not yet defined. When $soundcloud.set() set the sc field, the scope variable will never get notified since it only got the undefined value and know nothing about sc.

The easiest sollution is to create a wrapper object that will never be removed. Something like this.

app.service('$soundcloud', ['$http', function ($http) {    
var sc = {};    // define an object instead of undefined
this.set = function () {
    $http.get('/users/active/services/soundcloud')
        .success(function (obj) {
            sc.soundcloud = obj; // write to a field on the object, 
                                 // do not override sc, since sc is already returned
        })
        .error(function () {
            console.log('Error fetching soundcloud feed')
        })
}    
this.fetch = function () {
    return sc;
}    
}]);

When you use the result of $soundcloud.fetch() you need to refer to the soundcloud field. At least it will always get updated when $soundcloud.set is called



回答3:

You're right about it being a problem with the asynchronicity.

  1. The code in .run() triggers an asynchronous .get().
  2. Before the result comes back, and the callback passed to .success() is run, you run fetch(). As such, fetch() returns an undefined sc.
  3. You then console.log() this, hence you see undefined.
  4. The promise gets resolved with the data from your .get(), and you assign sc to the result.

The approach @Andre Paap has just outlined is a good one - essentially by creating a blank reference object and updating one of its properties on the callback (rather than overwriting the reference completely), you can still assign this to the scope, and angular will 'magically' update the view once it's populated (using the data binding). However, if you console.log() the result immediately, you'll still see the same thing.

In other words, if you had this inside your service:

var currentUser = {};    
this.set = function () {
    return $http.get('/users/active/profile')
        .success(function (obj) {
            currentUser.name = obj.name;
        })
        .error(function () {
            console.log('ERR');
        })
}    

And this in your template:

<span ng-show="currentUser.name"> Hello {{currentUser.name}}! </span>

Then putting this in your controller would result in the above span showing up correctly once the set() call has returned:

$scope.currentUser =  $user.fetch()
// NOTE: the below would still log undefined:
console.log($scope.currentUser.name) //<- undefined.

If you wanted to run some code only after the .set() call has been returned, then you can take advantage of the fact that $http methods return promises, like so:

$user.set().then(function(){
  // This code runs *after* the call has returned.
  $scope.currentUser = $user.fetch()
  console.log($scope.currentUser.name) //<- "Ed"
});