用 AngularJS 建立应用

这一指南使您初步了解如何使用 AngularJS MVC 框架建立 Chrome 应用。为了以实际例子演示 Angular,我们将引用一个使用该框架建立的实际的应用——Google Drive Uploader(Google 云端硬盘上传器),源代码在 GitHub 上可用。

关于该应用

Google Drive Uploader

Google Drive Uploader 允许用户快速地查看他们在 Google 云端硬盘中存储的文件并与之交互,还可以使用 HTML 拖放 API 上传新的文件。建立与某个 Google 的 API(在此例子中即 Google Drive API)交互应用是一个很好的例子。

注意:您也可以建立应用,与启用了 OAuth2 的第三方 API/服务交互。请参见非 Google 帐户的认证

上传器使用 OAuth2 访问用户数据。chrome.identity API 可以为已登录用户获取 OAuth 令牌,所以这些麻烦的工作已经为我们做好了!一旦我们获得了长时间有效的访问令牌,应用就可以使用 Google Drive API 访问用户数据。

该应用使用的核心特性包括:

创建清单文件

所有 Chrome 应用都需要一个 manifest.json 文件,包含 Chrome 浏览器启动应用所需的信息。清单文件包含相关的元数据,并列出应用运行时需要的所有特殊权限。

上传器清单文件的简化版本如下所示:

{
  "name": "Google Drive Uploader",
  "version": "0.0.1",
  "manifest_version": 2,
  "oauth2": {
    "client_id": "665859454684.apps.googleusercontent.com",
    "scopes": [
      "https://www.googleapis.com/auth/drive"
    ]
  },
 ...
  "permissions": [
    "https://docs.google.com/feeds/",
    "https://docs.googleusercontent.com/",
    "https://spreadsheets.google.com/feeds/",
    "https://ssl.gstatic.com/",
    "https://www.googleapis.com/"
  ]
}

该清单文件最重要的部分就是 "oauth2" 与 "permissions" 部分。

"oauth2" 部分定义了 OAuth2 需要的参数以便完成它的工作。要创建一个 "client_id",遵循获取您的客户端标识符中的指示。"scopes" 列出了 OAuth 令牌有效的认证域(例如应用需要访问的 API)。

"permissions" 部分包含应用通过 XHR2 访问的 URL。URL 前缀是必须的,这样 Chrome 浏览器才能知道允许哪些跨域请求。

创建事件页面

所有 Chrome 应用都需要一个后台脚本/网页才能运行应用,并响应系统事件。

在它的 background.js 脚本中,云端硬盘上传器打开一个 500×600 像素的窗口显示主页面,同时它也指定了窗口的最小高度与宽度,以免内容显得过于紧凑。

chrome.app.runtime.onLaunched.addListener(function(launchData) {
  chrome.app.window.create('../main.html', {
    id: "GDriveExample",
    bounds: {
      width: 500,
      height: 600
    },
    minWidth: 500,
    minHeight: 600,
    frame: 'none'
  });
});

窗口以无边框窗口的形式创建(frame: 'none')。默认情况下,窗口渲染时将显示操作系统默认的关闭/最大化/最小化栏:

Google Drive Uploader with no frame

上传器使用 frame: 'none' 使窗口渲染为“白板”,并在 main.html 中创建一个自定义的关闭按钮:

Google Drive Uploader with custom frame

整个导航区域包括在 <nav> 标签中(参见下一节)。为了使应用更简洁一些,自定义按钮一开始是隐藏的,直到用户与该区域交互:

<style>
nav:hover #close-button {
  opacity: 1;
}

#close-button {
  float: right;
  padding: 0 5px 2px 5px;
  font-weight: bold;
  opacity: 0;
  -webkit-transition: all 0.3s ease-in-out;
}
</style>
<button class="btn" id="close-button" title="Close">x</button>

app.js 中,该按钮将调用 window.close()

以 Angular 的方式设计应用

Angular 是一个 MVC 框架,所以我们需要以这样的方式定义应用,使模型、视图与控制器以逻辑的方式在应用中体现出来。幸运的是,在使用 Angular 时这是很普通的。

视图是最简单的,所以让我们从这里开始。

创建视图

main.html 是 MVC 中的“V”(视图),我们在其中定义呈现数据的 HTML 模板。在 Angular 中,模板是包含某些特殊内容的简单 HTML 块。

最终我们希望显示用户的文件列表。为了做到这一点,可以使用简单的 <ul> 列表。Angular 的特有内容以粗体高亮:

<ul>
  <li data-ng-repeat="doc in docs">
    <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a>
{{doc.size}}
    <span class="date">{{doc.updatedDate}}</span>
  </li>
</ul>

它理解起来与看上去的样子完全一致:在我们的数据模型“docs”中为每一个文档产生一个 <li>,每一项包含一个文件图标、在网页中打开文件的链接以及最后更新日期。

注意:为了使模板成为有效的 HTML,我们使用 data-* 属性作为 Angular 的 ngRepeat 迭代器,但您不一定要这么做。您可以简单地将重复器写为 <li ng-repeat="doc in docs">

接着,我们需要告诉 Angular 哪个控制器将会控制该模板的渲染。为了做到这一点,我们使用 ngController 指示符让 DocsController 控制模板的 <body>。

<body data-ng-controller="DocsController">
<section id="main">
  <ul>
    <li data-ng-repeat="doc in docs">
      <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a> {{doc.size}}
      <span class="date">{{doc.updatedDate}}</span>
    </li>
  </ul>
</section>
</body>

值得注意的是您在这里并没有看到我们设置事件监听器或用于数据绑定的属性,Angular 为我们完成了这些繁重的任务!

最后一步是让 Angular 使我们的模板生效,做到这一点的通常方法是直接在 <html> 上包含 ngApp 指示符:

<html data-ng-app="gDriveApp">

如果您愿意的话,您也可以将应用限制在页面中某一个较小的部分。在这一应用中我们只有一个控制器,但是如果今后我们需要增加更多,将 ngApp 放在顶层元素将使整个页面可以使用 Angular。

main.html 的最终产品如下所示:

<html data-ng-app="gDriveApp">
<head>
  …
  
  <base target="_blank">
</head>
<body data-ng-controller="DocsController">
<section id="main">
  <nav>
    <h2>Google Drive Uploader</h2>
    <button class="btn" data-ng-click="fetchDocs()">Refresh</button>
    <button class="btn" id="close-button" title="Close"></button>
  </nav>
  <ul>
    <li data-ng-repeat="doc in docs">
      <img data-ng-src="{{doc.icon}}"> <a href="{{doc.alternateLink}}">{{doc.title}}</a>  {{doc.size}}
      <span class="date">{{doc.updatedDate}}</span>
    </li>
  </ul>
</section>

有关内容安全策略的一些说明

与许多其他 JS MVC 框架不同,Angular v1.1.0+ 不需要任何调整就能在严格的 CSP 下工作。它真的可以直接使用!

然而,如果您正在使用 Angular 的旧版本(v1.0.1 与 v1.1.0 之间),您需要让 Angular 运行在“内容安全模式”下,在使用 ngApp 的同时包含 ngCsp 指示符即可做到这一点。

<html data-ng-app data-ng-csp>

进行认证

数据模型并不是由应用自己生成的,而是从外部 API(Google Drive API)获取的。因此,为了获取应用的数据有必要做一些事情。

在我们调用 API 请求前,我们需要获取用户 Google 帐户的 OAuth 令牌。为了做到这一点,我们创建了一个方法包装对 chrome.identity.getAuthToken() 的调用,并保存 accessToken,以便将来调用 Drive API 时再次使用。

GDocs.prototype.auth = function(opt_callback) {
  try {
    chrome.identity.getAuthToken({interactive: false}, function(token) {
      if (token) {
        this.accessToken = token;
        opt_callback && opt_callback();
      }
    }.bind(this));
  } catch(e) {
    console.log(e);
  }
};

注意:传递可选的回调函数使我们可以灵活地知道 OAuth 令牌什么时候已经准备好。

注意:为了简单起见,我们创建了一个 gdocs.js 库处理 API 任务。

一旦我们获得了令牌,现在可以向 Drive API 发出请求了,并获取模型。

基本控制器

上传器的“模型”是一个简单的数组(称为 docs),包含将渲染为模板中的那些 <li> 的对象。

var gDriveApp = angular.module('gDriveApp', []);

gDriveApp.factory('gdocs', function() {
  var gdocs = new GDocs();
  return gdocs;
});

function DocsController($scope, $http, gdocs) {
  $scope.docs = [];

  $scope.fetchDocs = function() {
     ...
  };

  // Invoke on ctor call. Fetch docs after we have the oauth token.
  gdocs.auth(function() {
    $scope.fetchDocs();
  });

}

注意,gdocs.auth() 作为 DocsController 构造函数的一部分调用。当 Angular 的内部实现创建控制器后,我们可以确定已经获取了最新的 OAuth 令牌等待用户。

获取数据

模板编写完成,控制器指定好了,OAuth 令牌也在手中了,接下来做什么?

现在应该定义控制器主方法 fetchDocs() 了。它将完成控制器的工作,负责请求用户的文件并用 API 响应中的数据填充 docs 数组。

$scope.fetchDocs = function() {
  $scope.docs = []; // First, clear out any old results

  // Response handler that doesn't cache file icons.
  var successCallback = function(resp, status, headers, config) {
    var docs = [];
    var totalEntries = resp.feed.entry.length;

    resp.feed.entry.forEach(function(entry, i) {
      var doc = {
        title: entry.title.$t,
        updatedDate: Util.formatDate(entry.updated.$t),
        updatedDateFull: entry.updated.$t,
        icon: gdocs.getLink(entry.link,
                            'http://schemas.google.com/docs/2007#icon').href,
        alternateLink: gdocs.getLink(entry.link, 'alternate').href,
        size: entry.docs$size ? '( ' + entry.docs$size.$t + ' bytes)' : null
      };

      $scope.docs.push(doc);

      // Only sort when last entry is seen.
      if (totalEntries - 1 == i) {
        $scope.docs.sort(Util.sortByDate);
      }
    });
  };

  var config = {
    params: {'alt': 'json'},
    headers: {
      'Authorization': 'Bearer ' + gdocs.accessToken,
      'GData-Version': '3.0'
    }
  };

  $http.get(gdocs.DOCLIST_FEED, config).success(successCallback);
};

fetchDocs() 使用 Angular 的 $http 服务通过 XHR 获取主要供稿,OAuth 访问令牌包含在 Authorization 头信息中,同时包含其他自定义头信息及参数。

successCallback 处理 API 响应并为供稿中每一项创建一个新的 doc 对象。

如果您现在运行 fetchDocs(),一切都将工作起来,并出现文件列表:

Fetched list of files in Google Drive Uploader

哇!

等等……我们好像少了整齐的文件图标。怎么会这样?迅速地检查一下控制台,显示了几个 CSP 相关的错误:

CSP errors in developer console

原因是我们尝试将图标的 img.src 设置为外部 URL,这违反了 CSP。例如:https://ssl.gstatic.com/docs/doclist/images/icon_10_document_list.png。要修复这一问题,我们需要将这些远程资源获取到本地供应用使用。

导入远程图片资源

为了不让 CSP 对我们大叫,我们使用 XHR2 将文件图标“导入”为 Blob,然后设置 img.src 为应用创建的 blob: URL

如下是更新后包含新增的 XHR 代码的 successCallback

var successCallback = function(resp, status, headers, config) {
  var docs = [];
  var totalEntries = resp.feed.entry.length;

  resp.feed.entry.forEach(function(entry, i) {
    var doc = {
      ...
    };

    $http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
      console.log('Fetched icon via XHR');

      blob.name = doc.iconFilename; // Add icon filename to blob.

      writeFile(blob); // Write is async, but that's ok.

      doc.icon = window.URL.createObjectURL(blob);

      $scope.docs.push(doc);

      // Only sort when last entry is seen.
      if (totalEntries - 1 == i) {
        $scope.docs.sort(Util.sortByDate);
      }
    });
  });
};

现在 CSP 对我们满意了,我们将得到漂亮的文件图标:

Google Drive Uploader with file icons

支持离线:缓存外部资源

需要进行的一个明显的优化就是:不要在每一次调用 fetchDocs() 时为每个文件图标发出 100 多个 XHR 请求。单击几次“刷新”按钮,就可以在开发者工具的控制台中验证这一点。每一次会获取 n 个图片:

Console log 65: Fetched icon via XHR

让我们修改 successCallback,增加一个缓存层。增加的部分以粗体高亮:

$scope.fetchDocs = function() {
  ...

  // Response handler that caches file icons in the filesystem API.
  var successCallbackWithFsCaching = function(resp, status, headers, config) {
    var docs = [];
    var totalEntries = resp.feed.entry.length;

    resp.feed.entry.forEach(function(entry, i) {
      var doc = {
        ...
      };

      // 'https://ssl.gstatic.com/doc_icon_128.png' -> 'doc_icon_128.png'
      doc.iconFilename = doc.icon.substring(doc.icon.lastIndexOf('/') + 1);

      // If file exists, it we'll get back a FileEntry for the filesystem URL.
      // Otherwise, the error callback will fire and we need to XHR it in and
      // write it to the FS.
      var fsURL = fs.root.toURL() + FOLDERNAME + '/' + doc.iconFilename;
      window.webkitResolveLocalFileSystemURL(fsURL, function(entry) {
        doc.icon = entry.toURL(); // should be === to fsURL, but whatevs.
        
        $scope.docs.push(doc); // add doc to model.

        // Only want to sort and call $apply() when we have all entries.
        if (totalEntries - 1 == i) {
          $scope.docs.sort(Util.sortByDate);
          $scope.$apply(function($scope) {}); // Inform angular that we made changes.
        }

      }, function(e) {
        // Error: file doesn't exist yet. XHR it in and write it to the FS.
        
        $http.get(doc.icon, {responseType: 'blob'}).success(function(blob) {
          console.log('Fetched icon via XHR');

          blob.name = doc.iconFilename; // Add icon filename to blob.

          writeFile(blob); // Write is async, but that's ok.

          doc.icon = window.URL.createObjectURL(blob);

          $scope.docs.push(doc);

          // Only sort when last entry is seen.
          if (totalEntries - 1 == i) {
            $scope.docs.sort(Util.sortByDate);
          }
        });

      });
    });
  };

  var config = {
    ...
  };

  $http.get(gdocs.DOCLIST_FEED, config).success(successCallbackWithFsCaching);
};

注意在 webkitResolveLocalFileSystemURL() 回调函数中,当循环到最后一项时我们调用了 $scope.$apply()。通常 $apply() 的调用不是必要的,Angular 会以某种方式自动检测数据模型的更改。然而在我们的情况中,我们已经增加了一层 Angular 意识不到的异步回调函数,当我们的数据模型更新时我们必须显式告诉 Angular。

第一次运行时,图标还不在 HTML5 文件系统中,window.webkitResolveLocalFileSystemURL() 的调用将会导致错误回调函数的执行。在这种情况下,我们可以重用之前的技术获取图片。这一次唯一的区别就是每一个 Blob 将写入文件系统(参见 writeFile())。控制台验证了这一行为:

Console log 100: Write completed

下一次运行时(或者按下“刷新”按钮),由于文件之前已经缓存了,传递给 webkitResolveLocalFileSystemURL() 的 URL 存在。应用将 doc.icon 设置为文件的 filesystem: URL,以免为了图标发起昂贵的 XHR。

通过拖放上传

上传器应用如果不能上传文件的话那它就在做假广告!

app.js 实现了一个名为 DnDFileController 的小型库,使用 HTML5 拖放来处理这一特性。它提供了从桌面拖动文件并将它们上传到 Google 云端硬盘的能力。

只要将此加入 gdocs 服务就完成了任务:

gDriveApp.factory('gdocs', function() {
  var gdocs = new GDocs();
  
  var dnd = new DnDFileController('body', function(files) {
    var $scope = angular.element(this).scope();
    Util.toArray(files).forEach(function(file, i) {
      gdocs.upload(file, function() {
        $scope.fetchDocs();
      });
    });
  });

  return gdocs;
});