More Robust Browser-Side Networking

字体大小 | |
[前端(javascript) 所属分类 前端(javascript) | 发布者 店小二05 | 时间 2017 | 作者 红领巾 ] 0人收藏点击收藏

What happens to your web application when used over a flaky network connection? Does it swallow errors and stop responding? Does it bounce users immediately to the browser’s built-in “no connection” page? Does it give you a way to continue using the application when network connectivity is restored?

It used to be acceptable to simply say that a web application could only be used when there was a consistent, reliable network connection. They are web applications , after all. But it’s time to stop using that excuse.

As our javascript applications get more sophisticated and web technologies expand to include things like client-side storage and sockets, our love affair with jQuery’s thin wrapper around XMLHttpRequest needs to end. It’s time we paid some attention to the browser-side network layer.

The Challenge

I’m working on an Ember.js application using Ember Data to communicate with a pretty typical Rails API. The app is content-creation heavy, and we really don’t want people to lose data if one or more requests happen to fail because they briefly lose connectivity.

Ideally, we want to:

Make it obvious to the user that they have unsaved data, but let them keep working Allow the system to resolve the problem itself if it can Help the unsaved data survive a page refresh Avoid rewriting our API

Ember provides some great tools for building robust web applications, but no easy way to do what we want to do out of the box. And reviewing the Ember Data adapters for building an offline-capable web application hasn’t led us to anything we’re thrilled about. They either require too many modifications to our API, abuse Ember Data internals, or are out of date. So, we’re tackling this ourselves.

A Reasonable Solution

The first step we’ve taken is to make saving data more robust. To begin, we needed to make some decisions and assumptions about the API and its interactions with the client:

We’re going to ignore the application’s data loading needs for the moment. Some operations in our application involve copying parts of our relational object graph that are not loaded on the client. These can’t be completed while offline, but will be queued. There are no side-effects on the server that are important to the client while offline . One example here is searching the server updates a search index when content is saved, but we don’t expect the search function to work until the user is able to load data from the server. (See the first point above.) The client app has an autosave that triggers periodically. For this first simple step, we’ll ensure that the saves persist to the API in order. We won’t persist the data across page refresh yet. It doesn’t take much (about 90 lines of CoffeeScript ) to sketch out a little queue for all the mutating requests (PUT, POST, DELETE), halt processing when an error happens, and retry the requests on a scheduled basis. This isn’t enough for our end goals, but it seems to work really well as a first step and could be enough to prevent annoying errors or lost data in some cases. A Note on Adapters

For this solution, we’re using the ActiveModelAdapter and swapped in a custom Synchronizer service in place of the adapter’s use of $.ajax. This would also work fine for the REST Adapter. Below is our re-implementation of the adapter’s ajax method to use that service.

ApplicationAdapter = DS.ActiveModelAdapter.extend synchronizer: Ember.inject.service('synchronizer') # Most of this is a boring re-implementation of the REST adapter's ajax method, # because there isn't a good way to swap it in otherwise. ajax: (url, type, options) -> new Ember.RSVP.Promise (resolve, reject) => hash = @ajaxOptions(url, type, options) hash.success = (payload, textStatus, jqXHR) => response = @handleResponse( jqXHR.status, Em.Object.create(), response || payload ) if (response instanceof DS.AdapterError) Ember.run(null, reject, response) else Ember.run(null, resolve, response) hash.error = (jqXHR, textStatus, errorThrown) => if (errorThrown instanceof Error) error = errorThrown else if (textStatus == 'timeout') error = new DS.TimeoutError() else if (textStatus == 'abort') error = new DS.AbortError() else error = @handleResponse( jqXHR.status, parseResponseHeaders(jqXHR.getAllResponseHeaders()), @parseErrorResponse(jqXHR.responseText) || errorThrown ) Ember.run(null, reject, error) # Stop using jQuery's ajax directly # Ember.$.ajax(hash) @get('synchronizer').handleRequest(hash)

The Synchronizer code below works with the ways that the adapater uses the jQuery#ajax method, and with our other assumptions mentioned above. Notice that any GET is just a pass-through to $.ajax.

SynchronizationQueueEntry = Em.Object.extend init: -> @set 'headers', Em.Object.create({}) type: null url: null data: null headers: null dataType: null contentType: null description: null # mimic a portion of the jqXHR interface to store headers in a serializable way setRequestHeader: (key, value) -> @get("headers").set(key, value) # returns a function that sets the necessary headers on a real jqXHR object getBeforeSend: -> ajaxHeaders = @get('headers') (xhr) -> for key in Ember.keys(ajaxHeaders) xhr.setRequestHeader key, ajaxHeaders[key] # returns an object to pass to jqXHR to perform the queued request jqueryOptions: Em.computed 'type', 'url', 'data', 'dataType', 'contentType', 'headers', -> type: @get('type') url: @get('url') data: @get('data') dataType: @get('dataType') contentType: @get('contentType') beforeSend: @getBeforeSend() Synchronizer = Em.Object.extend syncPushQueue: null syncInProgress: false # a request or chain of requests is in process syncingHalted: false # there was a problem and the process is stopped retrySyncInSeconds: 1 init: -> @set('syncPushQueue', Em.A([])) restartSyncing: -> if not @get('syncInProgress') @set('syncingHalted', false) @syncPush() handleRequest: (opts) -> if opts.type == "GET" Ember.$.ajax(opts) else entry = SynchronizationQueueEntry.create type: opts.type url: opts.url data: opts.data dataType: opts.dataType contentType: opts.contentType opts.beforeSend(entry) @get('syncPushQueue').pushObject entry # From the caller's standpoint, the handoff of the request was successful. # The ball is in the synchronizer's court now. opts.success({}, "success", {status: 200}) _runSyncPush: Em.observer 'syncPushQueue.[]', -> # Observers run synchronously -- don't do any real work here other than setting # up the execution of a method later. Em.run.once @, 'triggerSyncPushFromQueueChange' triggerSyncPushFromQueueChange: -> if !@get('syncInProgress') and !@get('syncingHalted') @syncPush() syncPush: -> @set('syncInProgress', true) entry = @get('syncPushQueue.firstObject') if entry? Ember.$.ajax(entry.get('jqueryOptions')).then (result) => @get('syncPushQueue').removeObject(entry) @set 'retrySyncInSeconds', 1 @set 'syncingHalted', false Em.run.once @, 'syncPush' , (jqXHR, textStatus) => if (jqXHR.readystate == 0) @set('syncingHalted', true) @set 'retrySyncInSeconds', @get('retrySyncInSeconds')*2 @set('syncInProgress', false) Ember.run.later @, 'syncPush', @get('retrySyncInSeconds') * 1000 else # TODO: Handle other errors else @set('syncInProgress', false)

Even these first fairly simple steps are enough to make our application more robust in the face of small problems with network connectivity. We plan to take this a few steps further so that users won’t lose their work if they refresh the page or close their browser tab before changes are synced to the server.

本文前端(javascript)相关术语:javascript是什么意思 javascript下载 javascript权威指南 javascript基础教程 javascript 正则表达式 javascript设计模式 javascript高级程序设计 精通javascript javascript教程

主题: jQueryRESTJavaScriptXMLJavaCoffeeScriptHeadUT
本文标题:More Robust Browser-Side Networking

技术大类 技术大类 | 前端(javascript) | 评论(0) | 阅读(11)