标签 angularjs 下的文章

让我们通过本文从 0 开始使用 TypeScript 创建一个 Angular 2 应用。

先看看运行效果?

如果想先看看运行效果,可以点击访问我们在 Plunker 上面准备的示例,应用其实做的事情很简单,打开页面之后,开始加载程序代码,当程序代码加载完成之后,在页面中显示应用组件,该组件包含 My First Angular 2 App 字符。

其文件目录结构如下:

angular2-quickstart
|-app/
|-|-app.component.ts
|-|-main.ts 
|
|-index.html
|-license.md

功能文件包括一个 index.html 文件以及 app/ 目录下面的两个 TypeScript 文件。

当然了,我们开发不仅仅只是想在 Plunker 上面可以运行就可以了的,而是需要做一个真正的应用,包括:

  1. 配置我们的开发环境
  2. 创建 Angular 应用的根组件
  3. 启动它以让其接管整个页面
  4. 创建主页面 index.html

开发环境

我们首先需要一个地方存储整个应用的程序文件(应用项目目录),一些 TypeScript 配置以及一些开发与运行时需要的库。

创建新的项目目录

mkdir angular2-quickstart
cd angular2-quickstart

配置 TypeScript

我们必须要对 TypeScript 编译器进行一上结特殊的设置。

在项目目录下面添加一个名为 tsconfig.json 的文件,并复制/粘贴以下代码:

{
  "compilerOptions": {
    "target": "es5",
    "module": "system",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": false,
    "noImplicitAny": false
  },
  "exclude": [
    "node_modules",
    "typings/main",
    "typings/main.d.ts"
  ]
}

TypeScript Typings

许多 JavaScript 库对 JavaScript 的功能与语法进行了扩展,而这些扩展很多是 TypeScript 编译器本身不识别的,我们需要通过 TypeScript 类型定义文件 —— d.ts 教会编译器如何编译这些扩展,在项目目录下面新建一个名为 typings.json 的文件,编辑复制并粘贴下面的代码片段:

{
  "ambientDependencies": {
    "es6-shim": "github:DefinitelyTyped/DefinitelyTyped/es6-shim/es6-shim.d.ts#6697d6f7dadbf5773cb40ecda35a76027e0783b2"
  }
}

添加项目必要的第三方库

我们推荐使用 npm 包管理工具管理第三方库。在项目目录中添加一个名为 package.json 的文件,复制并粘贴下面的代码片段:

{
  "name": "angular2-quickstart",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "npm run typings install",
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "lite": "lite-server",
    "start": "concurrent \"npm run tsc:w\" \"npm run lite\" ",
    "typings" : "typings"
  },
  "license": "ISC",
  "dependencies": {
    "angular2": "2.0.0-beta.6",
    "systemjs": "0.19.20",
    "es6-promise": "^3.0.2",
    "es6-shim": "^0.33.3",
    "reflect-metadata": "0.1.2",
    "rxjs": "5.0.0-beta.0",
    "zone.js": "0.5.14"
  },
  "devDependencies": {
    "concurrently": "^1.0.0",
    "lite-server": "^2.0.1",
    "typescript": "^1.7.5",
    "typings":"^0.6.8"
  }
}

然后在项目目录中执行下面的命令以安装必要的第三方库:

npm install

第一个 Angular 组件

组件(Component) 是 Angular 最基本的概念,组件管理视图——用于显示网页内容或者对用户的反馈作出响应,技术上讲,一个组件就是一个类或者一个视频模板,我们在创建 Angular 应用的过程中,就是创建很多个组件的过程。

创建一个子目录

我们希望整个应用的代码都保存在项目根目录下名为 app/ 的子目录下,在终端中执行下面的命令即可:

mkdir app
cd app

创建组件文件

现在,添加一个名为 app.component.ts 的文件,复制并粘贴以下代码:

import { Component } from 'angular2/core';

@Component({
  selector: 'my-app',
  template: '<h1>我的第一个 Angular 2 应用</h1>'
})
export class AppComponent {

}

组件类(Component class)

在该文件的最下方,我们添加了一个空的,不做什么事情的名为 AppComponent 的类,当我们需要去做一个具有实质性功能的应用时,可以去扩展该类,但是在快速入门这个项目中,它不需要做任何事情。

模块

Angular 应用是模块化的,它们会使用很多个不同的模块去实现特定的功能,绝大多数应用的文件都会导出一个事物,比如一个组件,我们的 app.component 文件导出了 AppComponent 类。

export class AppComponent {

}

导出使得这个文件变成了一个模块,而这个文件的名称一般就是模块的名称,如上所述, app.component 就是我们的第一个模块。

表单是任何一个应用的基石,在 Angular 2 中,表单的实现作了不少改变。

曾经在 Angular 1 中,我们使用 ngModel 将表单数据映射至应用的数据模型,但是在 Angular 2,我们可以使用表单控件更加明确地构建表单,虽然看上去,相比于 Angular 1,我们需要写更多的代码,但是却不可以让我们不再需要去解决那些烦人的 ngModel 声明与数据绑定问题了。

一个简单的表单示例

<form [ngFormModel]="loginForm" (submit)="doLogin($event)">
  <input ngControl="email" type="email" placeholder="Email" />
  <input ngControl="password" type="password" placeholder="Password"| />
  <button type="submit">Log in</button>
</form>

与其相应的组件 JS 代码如下:

import {Component, FormBuilder, Validators} from 'angular2/angular2'

@Component({
  selector: 'login-page',
  templateUrl: 'login-page.html'
})
export class LoginPage {
  constructor (fb: FormBuilder) {
    this.loginForm = fb.group({
      email: ["", Validators.required],
      password: ["", Validators.required]
    });
  }

  doLogin(event) {
    console.log(this.loginForm.value);
    event.preventDefault();
  }
}

当我们运行该应用时,浏览器中会展示如下的登录表单

    +--------------------+
    | Email              |
    +--------------------+

    +--------------------+
    | Password           |
    +--------------------+

    +--------------------+
    |      Log in        |
    +--------------------+

表单构建器(FormBuilder)

在上面的示例中,我们使用了 FormBuilder,它可以让我们可以快速方便的定义表单控件以及每一个控件需要绑定的验证器,在上面示例中,我们创建了两个文本输入框 emailpassword

this.loginForm = fb.group({
  email: ["", Validators.required],
  password: ["", Validators.required]
})

控件组(ControlGroup)

FormBuilder 创建的就是 ControlGroup 实例,我们称之为 form,除了使用 FormBuilder 之外,我们还可以直接手工构建 ControlGroup

this.loginForm = new ControlGroup({
  email: new Control("email", Validators.required),
  password: new Control("password", Validators.required)
});

但是在实际的开发中,使用 FormBuilder 会方便很多。

表单指令 (For,m Directives)

上面的示例中,你应该已经注意到了,没有使用一个 ngModel 声明,但是我们使用 ngControl 将表单中的值映射至控件对象中。

<input ngControl="email" type="email" placeholder="Email" />

这将 email 输入框 ”绑定“ 至了 email 控件。

自定义验证器

我们可以创建自定义的验证器,它就是一个简单的函数:

function containsMagicWord(c: Control) {
  if(c.value.indexOf('magic' >= 0) {
    return {
      noMagic: true
    }
  }
  
  // Null 表示验证通过
  return null;
}

this.loginForm = fb.group({
  email: ["", containsMagicWord],
  password: ["", Validators.required],
});

处理表单数据

我们可以通过很方便的访问表单对应的 JavaScript 对象,或者某一个控件的值:

doLogin(event) {
  // 展示表单的数据
  var formData = this.loginForm.value;
 
  // 或者获取控件的值
  var email = this.loginForm.controls.email.value;

  event.preventDefault();
}

在 Angular 2 中,使用括号 () 绑定事件,并触发组件类中相应的方法,比如:

@component(...)
class MyComponent {
  clicked(event) {

  }
}

模板如下:

<button (click)="clicked()">点击我</button>

委托

Angular 2 采用普通 DOM 事件一样的机制处理事件,它们同样可以冒泡,我们不需要做什么特别的工作。

事件对象

要访问事件对象,只需要将 $event 作为事件出发的​参数即可。

@Component(...)
class MyComponent {
  clicked(event) {
    console.log(event(
  }
}

模板如下:

<button (click)="clicked($event)">点击我</button>

Angular 2 应用的生命周期需要经过一个多级的启动过程,我们可以在 App 启动、运行与创建/销毁组件的过程中响应大家的事件。

启动

Angular 2 应用需要通过应用的 根组件 (root component) 启动,在我们的主 JS 文件中,我们可以像下面这样写:

import {Component, bootstrap} from 'angular2/angular2'

// 注解部分
@Component({
  selector: 'my-app',
  template: '<h1>你好,{{ name }}</h1>'
})
// 组件控制器
class MyApp {
  constructor() {
    this.name = '潘韬'
  }
}

bootstrap(MyApp)

该代码片段中,你可以添加应用级别的代码与配置等,它的模板将是其它所有组件的容器。

组件初始化

当一个组件创建成功之后,它的构建函数 (constructor) 将被调用,在该函数中,你可以执行组件状态的初始化工作,但是如果应用依赖子组件的属性与数据的话,那么需要先等待子组件先完成初始化。要实现这样的功能,只需要使用生命周期事件 ngOnInit 即可,我们可以在 constructor 中使用 setTimeout 来模拟出相似的效果。

import {Component, bootstrap} from 'angular2/angular2'

@Component({
  selector: 'street-map',
  template: '<map-window></map-window><map-controls></map-controls>'
})
class StreetMap {
  constructor() {
    this.name = '潘韬'
  }

  setMapWindow(mapWindow) {
    this.mapWindow = mapWindow;
  }

  setMapControls(mapControls) {
    this.mapControls = mapControls;
  }

  ngOnInit() {
    // Properties are resolved and things like
    // this.mapWindow and this.mapControls
    // had a chance to resolve from the
    // two child components <map-window> and <map-controls>
  }
}

组件的生命周期

就像 ngOnInit 一样,我们可以在一个组件的生命周期中跟踪多个事件,下面列举出了部分常见的事件,查看完整的 Angular 2 生命周期事件钩子,可以查看官方文档

import {Component} from 'angular2/angular2'

@Component({
  selector: 'street-map',
  template: '<map-window></map-window><map-controls></map-controls>'
})
class StreetMap {
  constructor() {
    this.name = '潘韬';
  }

  ngOnInit() {

  }

  ngOnDestroy() {

  }

  ngOnCheck() {

  }

  ngOnChanges(changes) {

  }

  ngAfterContentInit() {

  }

  ngAfterContentChecked() {

  }

  ngAfterViewInit() {

  }

  ngAfterViewChecked() {

  }
}

Ionic V2 与 Ionic V1 一样提供了 CLI 工具与 GUI 的工具。

安装 Ionic V2

安装 Ionic 2,可以使用下面的命令:

npm install -g ionic@beta
完全不需要担心 Ionic V1 版本的项目,Ionic@Beta 可以完全兼容 Ionic V1 的项目。

安装完成之后可以使用下面的命令创建新项目:

ionic start cutePuppyPics --v2

运行新创建的项目, cd 进入项目目录之后,运行 ionic serve 命令即可:

cd cutePuppyPics
ionic serve

执行之后,即可像 Ionic v1 一样在浏览器中查看项目了。

构建

要构建 Ionic 项目,需要先安装 cordova

sudo npm install -g cordova

iOS 构建

ionic platform add ios
你需要先安装 XCodeXCode 允许你直接为 iOS 系统的目标设备构建应用。添加了 iOS 系统之后,即可使用下面的命令运行模拟器:
ionic emulate ios

Android 构建

ionic platform add android

接下来你还需要安装 Android SDK ,Android SDK 允许你为 Android 目录设备构建应用,虽然 Android SDK 本身就带了一个模拟器,但是更加推荐你使用 Genymotion

ionic run android

迁移

Ionic 1.x 是基于 Angular 1.x 技术的,同样的 Ionic 2.x 基于 Angular 2.x,所以,虽然 Ionic 本身的理念没有太多改变,但是代码的写法也因为 Angular 的改变而变得很不一样,Angular 2.x 使用了完全不一样的语法与代码结构(要了解 Angular 2.x 的变化,可以查看 学习 Angular 2 这个网站。

下面这个是 Ionic 1.x 中的写法:

.config(function($stateProvider) {
  $stateProvider
  .state('main', {
    url: '/',
    templateUrl: 'templates/main.html',
    controller: 'MainController'
  })
})

.controller('MainController', function() {

})

现在在 Ionic 2.x 中可以像下面这样写:

@Page({
  templateUrl: 'main/main.html'
})

export class MainCmp {
  constructor() {

  }
}

从 Angular 1.x 迁移

ControllerAs 语法是一个 Angular 1.x 提供的功能,它可以我们不需要使用 $scope 变量即可做到数据绑定,而是将数据直接绑定至 Controller 上,在 Angular 2.x 中,ControllerAs 的实现更加简单了,对比下面是 Angular 1.x 实现:

index.html

<ion-content ng-controller="MainController">
  <ion-item>
    {{ data.text }}
  </ion-item>
</ion-content>

app.js

.controller('MainController', function($scope) {
  $scope.data = {
    text: 'hello world'
  }
})

将上面的示例改成 ControllerAs 语法只需要改变很小的一点代码:

index.html

<ion-content ng-controller="MainController as main">
  <ion-item>
    {{ data.text }}
  </ion-item>
</ion-content>

app.js

.controller('MainController', function() {
  this.data = {
    text: 'Hello World'
  }
})

TypeScript

TypeScript 是一个提供了 ES6 类 与类型注释的 JavaScript 超集 ,这使得我们可以在 Ionic 项目中按照 ES6 的标准来写。

app.js

.controller('MainController', function() {
  this.data = {
    text: 'Hello World'
  }
})

app.ts

export class MainController {
  constructor() {
    this.data = {
      text: 'Hello World'
    }
  }
}

项目结构

在 Angular 1.x 中,最好的项目实践是将所有的 JavaScript 脚本都放在一起,模板文件也放在一起,但是他们两者却是分开的,比如下面这样:

|-www/
|
|--js/
|--|-app.js
|--|-HomeController.js
|--|-DetailController.js
|
|--templates/
|--|-Home.html
|--|-Detail.html
|
|-index.html

但是在 Angular 2.x 中,推荐像下面这样的:

|-www/
|
|--Home/
|--|-HomeController.js
|--|-Home.html
|
|--Detail/
|--|-DetailController.js
|--|-Detail.html
|
|-index.html
|-app.js

我现在正在进行 信用生活 的业务运行系统的开发,使用了 AngularJS 以及 Material Design 以及 [Angular UI Router](),系统中涉及到了一个特别常见的问题——一个Router的网址带有可选的参数,比如:/cms/promotions/movies 对应的是 /cms/promotions/:promotionType ,然后我还需要网址中能带有分页参数以及每一页数据的条目数量参数,这些参数也还应该有默认值,这个时候就使用到了可选参数。

在 AngularJS UI Router 中有以下方法可以创建可选参数的路由。

Query Parameters

使用 Query Parameters 是最简单也是最常见的一种方式了, UI Router 会将定义的参数都添加至 $stateParams 对象中去,比如:

state('app.console.cms.promotions', {
  url: '/cms/promotions/:promotionType?page',
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType;
    $scope.page = $stateParams.page || 0;
  }
})

接着,你就可以向下面这样定义一个新的链接了:

<a ui-sref="app.console.cms.promotions({ promotionType: 'foods', page: 1})">美食</a>

如果有多个参数,也没有关系,直接把多个参数使用 & 符号连接即可:

state('app.console.cms.promotions', {
  url: '/cms/promotions/:promotionType?page&size',
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType;
    $scope.page = $stateParams.page || 0;
    $scope.size = $stateParams.size || 10;
  }
})

直接使用 Route Parameter

还有一种方式只适合只有一个可选参数的路由,比如:/cms/promotions/:promotionType

state('app.console.cms.promotions', {
  url: '/cms/promotions/:promotionType',
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType || 'foods';
  }
})

此时, /cms/promotions/ 就对应的就是参数没有值时的路由了,此时我在 Controller 里面使用了一个默认的 foods 值为其值。

但是对于多个参数的时候,我们也只能定义多个路由了:

state('app.console.cms.promotions', {
  url: '/cms/promotions/:promotionType',
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType || 'foods';
  }
})

...

state('app.console.cms.promotions', {
  url: '/cms/promotions/:promotionType/:page',
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType || 'foods';
    $scope.page = $stateParams.page || 0;
  }
})

非 URL 路由参数

还有一种方式,可以不通过 URL 即可定义参数:

state('app.console.cms.promotions', {
  url: '/cms/promotions',
  params: {
    promotionType: 'foods'
  },
  templateUrl: 'app/console/cms/promotions/promotion-list.tmpl.html',
  controller: function($scope, $stateParams) {
    $scope.promotionType = $stateParams.promotionType;
  }
})

此时,我们还是可以使用 ui-sref=app.console.cms.promotions({promotionType: 'movies'}) 创建一个链接 /cms/promotions,但是却会把 movies 做为 promotionType 的值传递给控制器。

doraemoney-loan-app.jpg

6月14号,和另外两个同事商量着不能再像最近这几个月这样了,似乎每一个公司的产品经理与码农们都是死对头,我也没有逃出这个怪圈,每天在对产品的“精雕细琢”中,让我对产品越发的反感,不经意间,看了看自己的 Git Commits List,好长啊,每天都有好多,然后就想着看看自己的干了些什么,突然之间,发现这就是一个循环啊,基本上是下面这样的:

for var keepGoing = true; keepGoing  {
    // 4B中
}

不行啊,我们得自己整一个,但是不能在上班时间整,因为这是一个只有我们参与的事情,而且也不希望他人对我们的指指点点,所以,决定每天的空余时间抽出几个小时,计划着一个星期之内整一个新的东西出来,恩,是的,App,最后还是花了我们3个人十天的时间。

这还是一个借款给有需要的人的App,没有风控模型,但是我们有完善的催债模型和真实性模型,我们只做一件事情,让借款给你的人更快的相信你在按时还款,所以,我们选择了通讯录、通话记录、地理位置、手机型号等这些通过一个App能快速获取到的数据。

然后我们开始了规划,简单的设计,接口的定义以及数据结构的定义,这花了一天的时间,我们按着花了三天时间把整个系统开发完了,也就是所有的功能都有了,接着花了两天时间把所有的功能接口串连上,最后再连了四天的时间测试、调试与Bug修复,Ok,一个全新的App就这么出来了。

技术使用的是:

  • Java
  • Ionic
  • Cordova
  • 一些必要的插件

Java

选择Java的原因很简单,也很纯粹,我们的核心业务系统就是Java的,为了能更快速的开发,我们还是直接使用Java,这样很多接口的代码可以直接复制过来改改就能使用,这为我们节省了很多开发的时间。

Ionic

这个不用想,简单的App开发中的神器,有了这个东西,即使我对App开发一无所知,我也能仅使用我自己会的前端技术实现一个完善的App。

Cordova

这为我们的App兼容到各种平台 iOA/Andoird等提供支持。

我是怎么做的

关于本地的数据存储

因为数据量很少,所以直接使用了 LocalStorage,我自己写了一个 AngularJSLocalStorage 的数据绑定的 Angular Module,代码如下:

/**
 * 本地存储
 */
app.factory('$storage', [
  '$rootScope',
  '$window',
  function(
      $rootScope,
      $window
  ){
    // #9: Assign a placeholder object if Web Storage is unavailable to prevent breaking the entire AngularJS app
    var webStorage = $window['localStorage'] || (console.warn('This browser does not support Web Storage!'), {}),
        storage = {
          $default: function(items) {
            for (var k in items) {
              angular.isDefined(storage[k]) || (storage[k] = items[k]);
            }

            return storage;
          },
          $reset: function(items) {
            for (var k in storage) {
              '$' === k[0] || delete storage[k];
            }

            return storage.$default(items);
          }
        },
        _laststorage,
        _debounce;

    for (var i = 0, k; i < webStorage.length; i++) {
      // #8, #10: `webStorage.key(i)` may be an empty string (or throw an exception in IE9 if `webStorage` is empty)

      (k = webStorage.key(i)) && 'storage-' === k.slice(0, 8) && (storage[k.slice(8)] = angular.fromJson(webStorage.getItem(k)));
    }

    _laststorage = angular.copy(storage);

    $rootScope.$watch(function() {
      _debounce || (_debounce = setTimeout(function() {
        _debounce = null;

        if (!angular.equals(storage, _laststorage)) {
          angular.forEach(storage, function(v, k) {
            angular.isDefined(v) && '$' !== k[0] && webStorage.setItem('storage-' + k, angular.toJson(v));

            delete _laststorage[k];
          });

          for (var k in _laststorage) {
            webStorage.removeItem('storage-' + k);
          }

          _laststorage = angular.copy(storage);
        }
      }, 100));
    });

    // #6: Use `$window.addEventListener` instead of `angular.element` to avoid the jQuery-specific `event.originalEvent`
    'localStorage' === 'localStorage' && $window.addEventListener && $window.addEventListener('storage', function(event) {
      if ('storage-' === event.key.slice(0, 10)) {
        event.newValue ? storage[event.key.slice(10)] = angular.fromJson(event.newValue) : delete storage[event.key.slice(10)];

        _laststorage = angular.copy(storage);

        $rootScope.$apply();
      }
    });

    return storage;
  }
]);

使用起来很简单:

$storage.token = 'TOKEN_STRING'; // 这就会在localStorage 中存储一个 `key` 为 `storage-token` 而 `value` 为 `TOKEN_STRING` 的键值对,这是一个单向存储的过程,也就是我们再手工修改 `localStorage` 里面的值是没有用的,`100ms` 之后就会被 `$storage.token` 的值覆盖,这是一个更新存储的时间。

数据请求

因为我们这边的接口走的不是 AngularJS 的默认请求方式,数据结构为类似表单提交,所以,我还修改了 Angular 中的 $http,转换对象为 x-www-form-urlencoded 序列代的字符串:

/**
 * 配置
 */
app.config([
  '$ionicConfigProvider',
  '$logProvider',
  '$httpProvider',
  function(
      $ionicConfigProvider,
      $logProvider,
      $httpProvider
  ) {
    // .. 其它代码
    // 开启日志
    $logProvider.debugEnabled(true);

    /**
     * 服务器接口端要求在发起请求时,同时发送 Content-Type 头信息,且其值必须为: application/x-www-form-urlencoded
     * 可选添加字符编码,在此处我默认将编码设置为 utf-8
     *
     * @type {string}
     */

    $httpProvider.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8';
    $httpProvider.defaults.headers.put['Content-Type'] = 'application/x-www-form-urlencoded;charset=utf-8';

    /**
     * 请求只接受服务器端返回 JSON 数据
     * @type {string}
     */
    $httpProvider.defaults.headers.post['Accept'] = 'application/json';

    /**
     * AngularJS 对默认提交的数据结构为 json 格式的,但是我们NiuBilitity的服务器端不能解析 JSON 数据,所以
     * 我们经 x-www-form-urlencoded 的方式提交,此处将对数据进行封装为 foo=bar&bar=other 的方式
     * @type {*[]}
     */
    $httpProvider.defaults.transformRequest = [function(data) {
      /**
       * 转换对象为 x-www-form-urlencoded 序列代的字符串
       * @param {Object} obj
       * @return {String}
       */
      var param = function(obj) {
        var query = '';
        var name, value, fullSubName, subName, subValue, innerObj, i;

        for (name in obj) {
          value = obj[name];

          if (value instanceof Array) {
            for (i = 0; i < value.length; ++i) {
              subValue = value[i];
              fullSubName = name + '[' + i + ']';
              innerObj = {};
              innerObj[fullSubName] = subValue;
              query += param(innerObj) + '&';
            }
          } else if (value instanceof Object) {
            for (subName in value) {
              subValue = value[subName];
              fullSubName = name + '[' + subName + ']';
              innerObj = {};
              innerObj[fullSubName] = subValue;
              query += param(innerObj) + '&';
            }
          } else if (value !== undefined && value !== null) {
            query += encodeURIComponent(name) + '='
                + encodeURIComponent(value) + '&';
          }
        }

        return query.length ? query.substr(0, query.length - 1) : query;
      };

      return angular.isObject(data) && String(data) !== '[object File]'
          ? param(data)
          : data;
    }];

  }
]);

JSON 请求数据结构

我们的数据结构是下面这样的:

Request

{
  "apiVersion" : "0.0.1",
  "token" : "TOKEN_STRING",
  "requestId" : "ID_STRING",
  "data" : {
    // Data goes here
  }
}

Response

{
  "apiVersion" : "0.0.1",
  "data" : {},
  "error" : {
    "code" : ERROR_CODE_NUMBER,
    "message" : "Error Message Here",
    "errors" : [
      {
        "code" : 0,
        "message" : "",
        "location" : ""
      }
    ]
  }
}

说明

在上面的这些数据结构中,请求的很好理解,响应的 json 结构只有三个字段, apiVersion 表示了当前请求的接口版本号, data 就是数据对象, error 则是错误对象,一般情况下,一个 error 只有 codemessage 两个值,但是有一些情况下可能会需要提供一些额外的错误信息,那么都放入了 error.errors 这个数组中。

App前端是下面这样的判断的:

  1. errornull 时,表示请求成功,此时从 data 中取数据;
  2. error 不为 null 时,表示请求失败,此时从 error 中取错误信息,而完全不管 data ,我采取的方式是直接抛弃(其实前后端已经约定了,所以不存在 error 不为 null 时,data 中还有数据的情况出现。

关于 $http

我没有直接将接口的 url 地址、$http 请求等暴露给 Controller,而是做了一层封装,我叫作为 sack(也就是 App 的名称):

app.factory('sack', [
  '$http',
  '$q',
  '$log',
  '$location',
  '$ionicPopup',
  '$storage',
  'API_VERSION',
  'API_PROTOCOL',
  'API_HOSTNAME',
  'API_URI_MAP',
  'util',
  function(
      $http,
      $q,
      $log,
      $location,
      $ionicPopup,
      $storage,
      API_VERSION,
      API_PROTOCOL,
      API_HOSTNAME,
      API_URI_MAP,
      util
  ){
    var HTTPUnknownError = {code: -1, message: '出现未知错误'};
    var HTTPAuthFaildError = {code: -1, message: '授权失败'};
    var APIPanicError = {code: -1, message: '服务器端出现未知错误'};
    var _host = API_PROTOCOL + '://' + API_HOSTNAME + '/',
        _map = API_URI_MAP,
        _apiVersion = API_VERSION,
        _token = (function(){return $storage.token;}()) ;

    setInterval(function(){
      _token = (function(){return $storage.token;}());
      //$log.info("Got Token: " + _token);
    }, 1000);

    var appendTransform = function(defaultFunc, transFunc) {
      // We can't guarantee that the default transformation is an array
      defaultFunc = angular.isArray(defaultFunc) ? defaultFunc : [defaultFunc];

      // Append the new transformation to the defaults
      return defaultFunc.concat(transFunc);
    };

    var _prepareRequestData = function(originData) {
      originData.token = _token;
      originData.apiVersion = _apiVersion;
      originData.requestId = util.getRandomUniqueRequestId();
      return originData;
    };

    var _prepareRequestJson = function(originData) {
      return angular.toJson({
        apiVersion: _apiVersion,
        token: _token,
        requestId: util.getRandomUniqueRequestId(),
        data: originData
      });
    };

    var _getUriObject = function(uon) {
      // 若传入的参数带有 _host 头
      if((typeof uon === 'string' && (uon.indexOf(_host) == 0) ) || uon === '') {
        return {
          uri: uon.replace(_host, ''),
          methods: ['post']
        };
      }

      if(typeof _map === 'undefined') {
        return {
          uri: '',
          methods: ['post']
        };
      }

      var _uon = uon.split('.'),
          _ns,
          _n;

      if(_uon.length == 1) {
        return {
          uri: '',
          methods: ['post']
        };
      }
      _ns = _uon[0];
      _n = _uon[1];

      _mod = _map[_ns];

      if(typeof _mod === 'undefined') {
        return {
          uri: '',
          methods: ['post']
        };
      }

      _uriObject = _mod[_n];

      if(typeof _uriObject === 'undefined') {
        return {
          uri: '',
          methods: ['post']
        };
      }

      return _uriObject;
    };

    var _getUri = function(uon) {
      return _getUriObject(uon).uri;
    };

    var _getUrl = function(uon) {
      return _host + _getUri(uon);
    };

    var _auth = function(uon) {
      var _uo = _getUriObject(uon),
          _authed = false;
      $log.log('Check Auth of : ' + uon);
      $log.log('Is this api need auth: ' + angular.toJson(_uo.needAuth));
      $log.log('Is check passed: ' + angular.toJson(!(!_token && _uo.needAuth)));
      $log.log('Token is: ' + _token);
      if(!_token && _uo.needAuth) {
        
        $ionicPopup.alert({
          title: '提示',
          subTitle: '您当前的登录状态已失效,请重新登录。'
        }).then(function(){
          $location.path('/sign');
        });
        
        $location.path('/sign');
      } else {
        _authed = true;
      }
      return _authed;
    };

    var get = function(uon) {
      return $http.get(_getUrl(uon));
    };

    var post = function(uon, data, headers) {
      var _url = _getUrl(uon),
          _data = _prepareRequestData(data);
      $log.info('========> POST START [ ' + uon + ' ] ========>');
      $log.log('REQUEST URL  : ' + _url);
      $log.log('REQUEST DATA : ' + angular.toJson(_data));

      return $http.post(_url, _data, {
        transformResponse: appendTransform($http.defaults.transformResponse, function(value) {
          $log.log('RECEIVED JSON : ' + angular.toJson(value));
          if(typeof value.ex != 'undefined') {
            return {
              error: APIPanicError
            };
          }
          return value;
        })
      });
    };

    var promise = function(uon, data, headers) {
      var defer = $q.defer();

      if(!_auth(uon)) {
        defer.reject(HTTPAuthFaildError);
        return defer.promise;
      }

      post(uon, data, headers).success(function(res){
        if(res.error) {
          defer.reject(res.error);
        } else {
          defer.resolve(res.data);
        }
      }).error(function(res){
        defer.reject(HTTPUnknownError);
      });
      return defer.promise;
    };

    var postJson = function(uon, data, headers) {
      var _url = _getUrl(uon),
          _json = _prepareRequestJson(data);
      $log.info('========> POST START [ ' + uon + ' ] ========>');
      $log.log('REQUEST URL  : ' + _url);
      $log.log('REQUEST JSON : ' + _json);
      return $http.post(_url, _json, {
        transformResponse: appendTransform($http.defaults.transformResponse, function(value) {
          $log.log('RECEIVED JSON : ' + angular.toJson(value));
          if(typeof value.ex != 'undefined') {
            return {
              error: APIPanicError
            };
          }
          return value;
        })
      });
    };

    var promiseJson = function(uon, data, headers) {
      var defer = $q.defer();

      if(!_auth(uon)) {
        defer.reject(HTTPAuthFaildError);
        return defer.promise;
      }

      postJson(uon, data, headers).success(function(res){
        if(res.error) {
          defer.reject(res.error);
        } else {
          defer.resolve(res.data);
        }
      }).error(function(res){
        defer.reject(HTTPUnknownError);
      });
      return defer.promise;
    };

    return {
      get: get,
      post: post,
      promise: promise,

      postJson: postJson,
      promiseJson: promiseJson,
      _auth: _auth,
      HTTPAuthFaildError: HTTPAuthFaildError
    };
  }
]);

这样里面最主要是使用一个方法: sack.promiseJson,这个方法是以 json 数据向服务器发送请求,然后返回一个 promise 的。

上面的 API_URI_MAP 的数据结构类似于下面这样的:

app.constant('API_URI_MAP', {
  user : {
    sign : {
      needAuth: false,
      uri : 'sack/user/sign.json',
      methods: [
        'post'
      ],
      params: {
        mobile: 'string', // 手机号码
        captcha: 'string' // 验证码
      }
    },
    unsign: {
      needAuth: true,
      uri: 'sack/user/unsign.json',
      methods: [
        'post'
      ],
      params: {
        token: 'string'
      }
    },
    //...
  }
  //...
});

然后,更具体的,在 Controller 中也不直接使用 sack.promiseJson 这个方法,而是使用封装好的服务进行,比如下面这个服务:

app.factory('UserService', function($rootScope, $q, $storage, API_CACHE_TIME, sack) {
  var sign = function(data) {
    return sack.promiseJson('user.sign', data);
  };

  return {
    sign: sign
  }
});

这样的好处是,我可以直接使用类似下面这样发起请求:

UserService.sign({mobile:'xxxxxxxxxxx',captcha:'000000'}).then(function(res){
  // 授权成功
}, function(err){
  // 授权失败
});

但是

好吧,又来但是了,App做完了之后,我们可爱的领导们感觉这个还可以,然后就又要开始发挥他们的各种NB的指导了,还好从一开始我们就没有使用上班时间,这使得我们有理由拒绝领导的指导,但是,公司却说了,不接受指导那就不让上,好吧,那就不上呗,这似乎惹怒了我们的领导们,所以,就直接没有跟我们通气的开始招兵买马要上App了,我瞬间就想问:

我们的战略不是说不做App么?现在怎么看到App比现在的简单就又开始做了

然后我又想到一种可能

  1. 我们把App上了,
  2. 另一个领导带招一些新人把也做了一个App
  3. 如果App还可以的话,把我们的功能直接复制过去,然后让我们的下线
  4. 然后领导又可以邀功了
  5. 如果App不可以的话,那我们是在浪费时间,把我们的下线,然后……

反正,似乎都跟我没半毛钱关系了,除非这个App运营的不好。

不知道 Cordova 、Ionic、AngularJS 为何物的,请点击下方链接自行恶补:

需求

最后开始接触一些基于 Ionic + Cordova + AngularJS技术的移动App的开发,然后就遇到了很多都是需要有一个分享功能的,尤其是微信的分享,这也是第一次搞这种事情啊,没办法,折腾了好多天,解决了这个问题之后发现,原来是如此的简单。

准备好你的App

我在这里创建一个名为 WechatShareDemoApp 的新的App,在工作目录中运行如下命令:

ionic start WechatShareDemoApp tabs

这会使用 Ionic Tabs Seed 创建一个基于标签导航的空的App,创建成功之后,使用任何一个你喜欢的编辑器编辑该项目,我使用的是 WebStorm。

DashCtrl 增加一个 share(title,desc,url,thumb) 文法

share(title,desc,url,thumb) 方法用来打开一个 ActionSheet 面板,在该面板中,会提供两个分享按钮,一个用于分享内容至朋友圈,一个用于分享至会话,代码如下:

// 上面代码省略
.controller('DashCtrl', function($scope, $ionicActionSheet) {
    $scope.share = function(title, desc, url, thumb) {
        $ionicActionSheet.show({
            buttons: [
                { text: '<b>分享至微信朋友圈</b>' },
                { text: '分享给微信好友' }
            ],
            titleText: '分享',
            cancelText: '取消',
            cancel: function() {
                // 取消时执行
            },
            buttonClicked: function(index) {
                if(index == 0) {
                    $scope.shareViaWechat(WeChat.Scene.timeline, title, desc, url, thumb);
                }
                if(index ==1 ) {
                    $scope.shareViaWechat(WeChat.Scene.session, title, desc, url, thumb);
                }
            }
        });
    };
})
// 下面代码省略

share(title,desc,url,thumb) 的运行方式是,点击该方法被调用时,打开一个 ActionSheet ,点击 分享至微信朋友圈 按钮被点击时,执行 $scope.shareViaWechat(WeChat.Scene.timeline, title, desc, url, thumb),点击 分享给微信好友 时,执行 $scope.shareViaWechat(WeChat.Scene.session, title, desc, url, thumb),前者分享内容至朋友圈,后者分享内容至会话,但是,$scope.shareViaWechat 方法本身是不存在的,所以,我们还需要添加该方法。

DashCtrl 添加 $scope.shareViaWechat 方法

该方法使用了第三方的 Cordova 插件提供的方法 WeChat.share,该方法可以将内容分享至微信中,代码如下:

// 前面代码省略
.controller('DashCtrl', function($scope, $ionicActionSheet, $ionicPopup) {

    //......        

    $scope.shareViaWechat = function(scene, title, desc, url, thumb) {
        // 创建消息体
        var msg = {
            title: title ? title : "行者无疆",
            description: desc ? desc : "A real traveller's province is boundless.",
            url: url ? url : "https://pub.ofcrab.com",
            thumb: thumb ? thumb : null
        };

        WeChat.share(msg, scene, function() {
            $ionicPopup.alert({
                title: '分享成功',
                template: '感谢您的支持!',
                okText: '关闭'
            });
        }, function(res) {
            $ionicPopup.alert({
                title: '分享失败',
                template: '错误原因:' + res + '。',
                okText: '我知道了'
            });
        });
    };
})
// 后面代码省略

在上面的代码中, WeChat 是由第三方插件提供的,至现在为止,分享功能已经做完,我们现在需要在 views 中添加分享功能的激活按钮。

添加分享按钮

打开 templates/tab-dash.html,在 ion-content 结束前,添加如下代码:

<button class="button button-assertive button-outline button-block" ng-click="share()">分享</button>

该按钮会打开分享的 ActionSheet,我们不需要传任何参数,因为在上面的分享方法中,对没有设定的参数已经提供了默认的内容了。

此时,应用的界面如下图所示:

微信分享 ActionSheet.jpg

添加微信分享插件

虽然样子已经成型了,但是还是不能进行分享的,我们需要安装一个插件,该插件使用到了微信官方的第三方开发库,需要先在微信开放平台申请一个AppId,然后还需要使用 Gen_Signature_Android221cbf.zip 插件从手机中根据包名获取一个 Signature Code,该代码还必须填写进入开放平台中,在该过程中,若我们的App是运行在Android平台的话,还涉及到 keystore 的问题,这里不做过多的阐述,若我们的项目为新项目的话,还需要先生成一个Android安装包(必须生成 Apk 包之后安装),安装至某一个手机中,然后在该手机中使用上面的 Gen_Signature_Android221cbf 应用获取 Signature 值,再填入开放平台的相应设置中。

您在申请了AppID之后,还需要了解到你需要将你的包名也设置进入开放平台中,该包名在 Ionic 项目中的, config.xml 文件中可以进行设置。

该插件必须在 Platform 添加之后安装,所以我们先添加一个 Platform,比如 Android

ionic platform add android

在 App 的根目录下使用下面的命令即可:

cordova plugin add com.wordsbaking.cordova.wechat --variable APP_ID=[你的APPID]

上面的 APP_ID 为你在微信开放平台申请的 App ID,安装该插件时,必须添加至命令中。

打包并安装使用

将新生成的项目专稿 Eclipse 中,打包并安装测试。