HTML5 features are already widely supported by most of the modern browsers. In this chapter, we're going to take a look at some of them while building two small apps:
dartlang
from www.reddit.com via JSONP, renders them into an HTML list, and allows you to save interesting ones to local IndexedDBOn top of that, we'll take a quick look at some technologies commonly used with graphics and games and a few notes about performance, low-level typed arrays, and Dart's Isolates.
If you're planning to support older browsers with limited support of HTML5 features, you can take a look at http://caniuse.com to see what's available to you before you start developing.
Both apps are going to heavily use Dart's Future-Based API for all asynchronous operations with async
and await
keywords that we talked about in the previous chapter.
We can start writing the app right away. We can split it into three separate parts:
It's fair to say that this example doesn't use any special HTML5 features apart from IndexedDB. What's interesting here is how it combines multiple things that we've seen in previous chapters, most importantly, the integration of JavaScript within Dart, its Future-Based API, and JsObject
proxy objects. You can use exactly the same approach for any JavaScript that needs to call Dart callbacks.
Simply said, JSONP is a JavaScript object wrapped into a function call and included just like any other JavaScript file (http://en.wikipedia.org/wiki/JSONP). Our app is entirely client side. This means that we can't call Ajax requests to servers on a different domain to ours, and unless the remote server returns appropriate Access-Control-Allow-Origin (ACAO) headers, all responses will be blocked by the browser due to the same-origin policy (this is mostly for security reasons).
With JSONP, we can avoid browser restrictions and make use of cross-origin resource sharing, aka CORS (http://en.wikipedia.org/wiki/Cross-origin_resource_sharing).
In practice, the JSON response would look like this:
{"key1":"value1","key2":"value2"}
The JSONP response is wrapped into a function call:
callback({'key1': 'value1', 'key2': 'value2'});
We're going to use JSONP to get a list of the latest posts on www.reddit.com that are tagged as dartlang
from http://www.reddit.com/r/dartlang/search.json?sort=new&jsonp=callback&limit=10&restrict_sr=on.
The Reddit API allows us to set a custom callback with the jsonp
parameter. This is the name of the function that will wrap the entire JavaScript object for us. When using Dart, all JavaScript has to be run in an isolated scope and as we want to be able to refresh Reddit posts anytime we want, we're not going to statically include the preceding URL but rather include it on demand and proxy the JavaScript callback function with ours using dart:js
:
// web/main.dart import 'dart:html'; import 'dart:js'; import 'dart:async'; class RedditReadLater { static const String jsonpUrl = 'http://www.reddit.com/...'; Future<List> refreshReddit() { // At this point we have to use [Completer] class to return a // Future object and complete it when we process all items // in the JavaScript callback. var completer = new Completer(); // Just like window.callback = function() {} in JavaScript. // Context is defined in [dart:js] package. context['callback'] = (JsObject jsonData) { JsArray redditItems = jsonData['data']['children']; List<Map<String, String>> items; // Iterate all items in the JavaScript array. for (int i = 0; i < redditItems.length; i++) { JsObject item = redditItems[i]['data']; items[i] = ({ 'title': item['title'], 'url': item['url'] }); } completer.complete(items); }; // To dynamically reload data we need to create // and append <script> element every time. ScriptElement script = new ScriptElement(); script.src = jsonpUrl; document.body.children.add(script); // Return a Future object that will be resolved when the // included script is loaded and the callback() method called. return completer.future; } void renderReddit(UListElement targetUl, List items) { // Remove all children and populate it with new ones. var frag = new DocumentFragment(); for (Map<String, String>item in items) { /* Fill the <ul> list with items */ } } }
For a detailed explanation of the Reddit API, refer to http://www.reddit.com/dev/api.
Now we can write the stub code for our main()
function that will use the Future
object from refreshReddit()
:
void main() { var readLater = new RedditReadLater(); // We can set an optional parameter "_" which is required by // onClick stream listener although we don't need it. refresh([_]) async { var items = await readLater.refreshReddit(); readLater.renderReddit( document.querySelector('#reddit-list'), items); } document.querySelector('#refresh-btn').onClick.listen(refresh); refresh(); }
Our HTML template for this app is going to be very short:
<body> <h2>Latest posts on reddit.com/r/dartlang [<a href="#" id="refresh-btn">refresh</a>] </h2> <ul id="reddit-list"></ul> <h2>Read later</h2> <ul id="saved-articles"></ul> </body>
The final app filled with posts from www.reddit.com will look like this in the browser:
When we know how to fetch and display data via the Reddit API, we can save them to IndexedDB.
IndexedDB is a transactional client-side storage API with support for high performance searches using indices and is implemented in almost every browser (including mobile browsers). It's designed specifically to store JavaScript objects.
For more information, you can refer to http://www.w3.org/TR/IndexedDB/ or https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API.
There are also some restrictions for IndexedDB:
It's a good practice to check whether a client's browser supports HTML5 APIs that might not be available in older browsers. With the dart:indexed_db
Dart package, you can check it thanks to IdbFactory.supported
:
import 'dart:indexed_db' as idb; if (!idb.IdbFactory.supported) { return; }
During development, it's very useful when you can see what's in your IndexedDB right in Dartium's Developer Tools:
Suppose you end up in a situation where your object store is corrupted or you want to test the behavior when the database doesn't exist yet. In this case, you can right-click on the object store name in Developer Tools and select the Clear option from the context menu, and remove it completely.
First, include the dart:indexed_db
package with a prefix because it contains class names that are already used in dart:html
(for example, the Request
class):
import 'dart:indexed_db' as idb;
To create or open a database, call:
var db = await window.indexedDB.open('databaseName', version: 1, onUpgradeNeeded: onUpgradeNeededCallback );
The onUpgradeNeededCallback
function is called when you're opening a database with this name for the first time or when the version number is higher than the version of the currently existing database that allows you to migrate to a newer database structure.
Records in the database are stored in object stores, which is much like a database table from relational databases. Each database can contain multiple object stores independently on each other. As we'll use the object store name and a reference to our database multiple times in our app, we can create them as object properties:
static const String objectStoreName = 'articles_store'; idb.Database _db;
Creating an object store for our database is very simple:
void onUpgradeNeededCallback(idb.VersionChangeEvent e) { idb.Database db = (e.target as idb.Request).result; var store = db.createObjectStore( objectStoreName, autoIncrement: true); }
A callback to onUpgradeNeeded
is the only place where you can create new object stores. Setting autoIncrement
to true
makes an object store to take care of generating unique keys for new records inserted to the object store.
All operations in IndexedDB are performed via transactions. The first argument to transaction()
is its scope, which can be multiple object stores. The second argument is a mode that can be readonly
or readwrite
. You can perform multiple readonly
operations concurrently but only one readwrite
operation at the time.
We're going to simply get a cursor
object that points at the beginning of our object store and gets one record at a time. When used as a stream, it fires events for every record in the object store. The autoAdvance
parameter set to true
makes the cursor automatically move to the next record; otherwise, we would have to call the next()
method manually:
loadDataFromDB() async { var trans = _db.transaction(objectStoreName, 'readonly'), var store = trans.objectStore(objectStoreName); Map<String, Map<String, String>>dbItems = new Map(); var cursors = store.openCursor( autoAdvance: true).asBroadcastStream(); // Bind an event which is called for each record. cursors.listen((cursor) { dbItems[cursor.key] = cursor.value; }); // Wait until all records have emitted their events. await cursors.length; renderReadLaterItems(dbItems); }
The renderReadLaterItems()
method just renders all records into a list just like the renderReddit()
method.
You can, of course, fetch a single record by its key
, which, again, returns a Future
object:
store.getObject(key)
We need to update the renderReddit()
method to handle our onClick
event, add a clicked item to the object store, and reload all records:
void renderReddit(UListElement targetUl, List items) { var frag = new DocumentFragment(); for (Map<String, String>item in items) { AnchorElement aElm = new AnchorElement(); ..text = '[save]'; ..href = '#'; ..onClick.listen((e) async { e.preventDefault(); await this._save(item); // Let's not worry about performance now. loadDataFromDB(); }); /* … */ frag.append(liElm); } targetUl.children.clear(); targetUl.append(frag); }
Here we're meeting Future
objects again:
Future _save(Map item) { var trans = this._db.transaction(objectStoreName, 'readwrite'), var store = trans.objectStore(objectStoreName); // add() method persists record and returns a [Future] object. // Item's key is generated by our object store. store.add(item).then((addedKey) => print(addedKey)); // We're not using await because only the transactions can tell // when it's finished. [Future.then()] is just better here. // [Transaction.completed] is an instance of [Future]. return trans.completed; }
Note that in order to remove a record from the object store, we need to know its database key:
Future _delete(key) { var trans = this._db.transaction(objectStoreName, 'readwrite'), // Delete record by its generated key. trans.objectStore(objectStoreName).delete(key); return trans.completed; }
Similarly, we can remove all records:
trans.objectStore(objectStoreName).clear();
Although we didn't need any indicies in our app, it's worth mentioning how to use them. Just like all operations that modify database structures, we have to define indices in the onUpgradeNeeded
callback:
store.createIndex('index_name', 'col_to_index', unique: true);
If we wanted, for example, to search our saved Reddit posts by their title, we would set the index like this:
store.createIndex('title_index', 'title'),
Let's set a search query to filter, say, only those posts that start with the letter R
:
var trans = this._db.transaction(objectStoreName, 'readonly'), var store = trans.objectStore(objectStoreName); var index = store.index(titleIndex); var range = new idb.KeyRange.lowerBound('R'), var cursors = index.openCursor( range: range, autoAdvance: true).asBroadcastStream(); cursors.listen((cursor) { print(cursor.value['title']); });
This is very similar to fetching all records, as we did a moment ago.
Note that we're opening the cursor over index
instead of the entire store and passing a range
argument, which is basically our search query. The idb.KeyRange
item has four named constructors that you can use:
Key name |
Description |
---|---|
|
Matches all values between these two bounds. |
|
Matches all values with this lower bound and everything after it. |
|
Single value key range. Indexed column needs to match this value. |
|
Matches all values with this upper bound and everything before. |
Compared to any SQL-like database, indices in IndexedDB are very primitive, but thanks to its simplicity, IndexedDB can be available even on mobile devices with very limited resources.
LocalStorage is a key-value storage that can be used instead of IndexedDB in some situations. For example, in the application that we have just created, we could use LocalStorage instead of IndexedDB without any problem, but remember that LocalStorage is a very primitive storage and in terms of use cases, it's like cookies with less restrictions.
If you're looking for a universal storage engine with multiple backends integrated, take a look at https://pub.dartlang.org/packages/lawndart.
3.145.50.222