Skip to content

Latest commit

 

History

History
1685 lines (1360 loc) · 88.1 KB

README-zh-cn.md

File metadata and controls

1685 lines (1360 loc) · 88.1 KB

AngularJS 模式

目录

(由于 AngularJS 的组件名称和专有术语通常会被直接用于程序源代码中,此中文版会尽量将此类词汇保留为原有英文,或将其译名放置在注释内,以避免歧义。如果您对本译文有任何改进建议,请提交 Pull Request。)

学习新事物的最好方式之一就是观察其如何运用整合已知的知识。本文将介绍面向对象、设计模式和架构模式的基本概念,而非向读者传授如何熟练使用这些设计或架构模式。本文的主旨是介绍 AngularJS 框架中的各种软件设计和架构模式以及如何在 AngularJS 单页应用中运用它们。

本文将首先简要介绍 AngularJS 框架,分析其主要构件 - directive、filter、controller、service 和 scope。第二部分会依照 AngularJS 构件组成顺序,分别列述 AngularJS 框架所实现的各种设计和架构模式,其中会特别注出被多个构件共同使用的模式。

在文章最后还会提及一些 AngularJS 单页应用中常用的架构模式。

AngularJS 是由 Google 开发的 JavaScript 框架,其提供了一个用于开发 CRUD 单页应用 (SPA) 的稳健基础。单页应用指的是一旦网页完成加载后,用户进行任何操作都无需重新加载完整的网页。这也意味着所有应用资源 (数据、模版、代码、样式) 都应该预先完成加载,或者更理想的是按需加载。由于大部分 CRUD 应用都包含共通的特质,AngularJS 提供了一套经过优化的现成工具实现来满足此需求。其中重要特质包括:

  • 双向数据绑定 (two-way data binding)
  • 依赖注入 (dependency injection)
  • 关注点分离 (separation of concerns)
  • 可测试性 (testability)
  • 抽象化 (abstraction)

关注点分离是依靠将 AngularJS 应用划分为相互隔离的构件来实现的,如:

  • partials (片段)
  • controllers (控制器)
  • directives (指示器)
  • services (服务)
  • filters (筛选器)

这些构件可以在不同的模块中组合,以帮助实现更高层级的抽象化以及处理复杂事件。每个单独构件都会封装应用程序中特定部分的逻辑。

Partials

Partial (模版片段) 实际就是 HTML 字符串,其元素和属性中可能包含一些 AngularJS 表达式。与 mustache.js 和 handlebars 等框架相比,AngularJS 的一个不同之处就是其模版并非是一种需要转化为 HTML 的中间格式。

每个单页应用都会在初始化时读取 index.html 文件。在 AngularJS 中,此文件包含有一套用来配置和启动应用程序的标准的和自定义的 HTML 属性、元素和注释。接下来的每个用户操作将仅仅加载一个 partial 文件或者通过软件框架的数据绑定等方式来改变应用的当前状态。

Partial 示例

<html ng-app>
 <!-- Body tag augmented with ngController directive  -->
 <body ng-controller="MyController">
   <input ng-model="foo" value="bar">
   <!-- Button tag with ng-click directive, and
          string expression 'buttonText'
          wrapped in "{{ }}" markup -->
   <button ng-click="changeFoo()">{{buttonText}}</button>
   <script src="angular.js"></script>
 </body>
</html>

Partial 文件可以通过 AngularJS 表达式来定义不同用户交互操作所对应的行为。例如在上面的例子中,ng-click 属性的值表示将执行当前 scope 中的 changeFoo 函数。

Controllers

AnuglarJS 中的 controller (控制器) 本质上就是 JavaScript 函数。它可以通过将函数绑定到对应的 scope 上来帮助处理用户与网页应用的交互操作 (例如鼠标或键盘事件等)。Controller 所需要的所有外部构件都是通过 AngularJS 的依赖注入 (Dependency Injection) 机制实现。Controller 还会将数据也绑定到 scope 上,从而给 partial 提供模型 (model) 功能。我们可以将这些数据看成是视图模型 (view model)。

function MyController($scope) {
  $scope.buttonText = 'Click me to change foo!';
  $scope.foo = 42;

  $scope.changeFoo = function () {
    $scope.foo += 1;
    alert('Foo changed');
  };
}

如果将以上 controller 示例与前一节中的 partial 示例结合在一起,用户就可以在应用程序中进行一些不同的交互操作。

  1. 通过改写输入框中的文本来改变 foo 的值。由于这里使用了双向数据绑定,foo 值会立刻改变。
  2. 点击 Click me to change foo! 按钮来改变 foo 的值。

所有自定义的元素、属性、注释或类,只要被预先定义过,就都可以被 AngularJS 的 directive 识别。

Scope

在 AngularJS 中,scope 是一个开放给 partial 的 JavaScript 对象。Scope 可以包含不同的属性 - 基本数据 (primitives)、对象和函数。所有归属于 scope 的函数都可以通过解析该 scope 所对应 partial 中的 AngularJS 表达式来执行,也可以由任何构件直接调用该函数 (使用这种方式将保留指向该 scope 的引用不变)。附属于 scope 的数据可以通过使用合适的 directive 来绑定到视图上,如此一来,所有 partial 中的修改都会映射为某个 scope 属性的变化,反之亦然。

AngularJS 应用中的 scope 还有另一个重要的特质,即它们都被连接到一条原型链 (prototypical chain) 上 (除了那些被表明为独立 (isolated) 的 scope)。在这种方式中,任何子 scope 都能调用属于其父母的函数,因为这些函数是该 scope 的直接或间接原型的属性。

以下例子展示了 scope 继承关系:

<div ng-controller="BaseCtrl">
  <div id="child" ng-controller="ChildCtrl">
    <button id="parent-method" ng-click="foo()">Parent method</button>
    <button ng-click="bar()">Child method</button>
  </div>
</div>
function BaseCtrl($scope) {
  $scope.foo = function () {
    alert('Base foo');
  };
}

function ChildCtrl($scope) {
  $scope.bar = function () {
    alert('Child bar');
  };
}

尽管 div#child 归属于 ChildCtrl,但由于 ChildCtrl 中所注入的 scope 通过原型继承了其父 scope (即 BaseCtrl 中所注入的 scope),因此 button#parent-method 就可以接触到 foo 函数。

Directives

在 AngularJS 中,所有 DOM 操作都应该放置在 directive 内。作为一个经验法则,每当你的 controller 里出现了 DOM 操作,你就应该创建一个新的 directive 或者考虑重构现有的 directive 用来处理 DOM 操作需求。每个 directive 都有其自己的名称和逻辑。最简化的 directive 仅仅包含名称和 postLink 函数定义,其中封装了所有该 directive 所需的逻辑。较复杂的 directive 可以包含很多属性,例如:

  • template (模版)
  • compile 函数
  • link 函数
  • 等等

通过引用 directive 的名称,这些属性可以被用于声明该 directive 的 partial 中。

示例:

myModule.directive('alertButton', function () {
  return {
    template: '<button ng-transclude></button>',
    scope: {
      content: '@'
    },
    replace: true,
    restrict: 'E',
    transclude: true,
    link: function (scope, el) {
      el.click(function () {
        alert(scope.content);
      });
    }
  };
});
<alert-button content="42">Click me</alert-button>

在上述例子中,<alert-button></alert-button> 标签会被按钮元素所替换。当用户点击按钮时,会弹出显示 42 的警告框。

由于本文的关注点并非分析 AnuglarJS 的完整 API,directive 就解释到这里为止。

Filters

AngularJS 中的 filter (筛选器) 负责封装数据格式化所需的逻辑。Filter 通常被用在 partial 中,但也可以通过依赖注入方式在 controller、directive、service 以及其它 filter 中使用。

以下定义了一个 filter 范例,用来将给定的字符串转变为大写。

myModule.filter('uppercase', function () {
  return function (str) {
    return (str || '').toUpperCase();
  };
});

此 filter 可以通过 Unix 管道语法在 partial 中使用。

<div>{{ name | uppercase }}</div>

在 controller 中,filter 可以按如下方式使用:

function MyCtrl(uppercaseFilter) {
  $scope.name = uppercaseFilter('foo'); //FOO
}

Services

所有其它逻辑,如果不属于以上所述构件,则应该放置到 service (服务) 中。Service 通常会封装领域专用逻辑 (domain specific logic)、持久层逻辑 (persistence logic)、XHR、WebSockets 等。当应用程序中 controller 变得过于臃肿时,就应该考虑将重复的代码放入一个 service 中。

myModule.service('Developer', function () {
  this.name = 'Foo';
  this.motherLanguage = 'JavaScript';
  this.live = function () {
    while (true) {
      this.code();
    }
  };
});

Service 可以被注入到任何支持依赖注入机制的构件中,例如 controller、其它 service、filter 和 directive。

function MyCtrl(Developer) {
  var developer = new Developer();
  developer.live();
}

我们将在接下来的几节中探讨传统的设计和架构模式是如何在 AngularJS 的各个构件中组合实现的。并在最后一节讨论使用 AngularJS (或其它框架) 开发单页应用程序时常用的架构模式。

Services

单例模式是一种软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。如果某个系统在仅有单个对象或者有限个数的对象实例的环境中运行更加高效,也时常被归属为单例模式概念。

以下 UML 图展示了单例设计模式。

Singleton

AngularJS 会按照以下算法来解决构件所需要的依赖关系:

  • 提取所依赖组件的名称并查询哈希表 (该表是定义在一个闭包中,所以外部不可见)。
  • 如果此依赖组件已经存在于应用中,AngularJS 会在需要的时候以参数的形式将其传递给对应的构件。
  • 如果所依赖的组件不存在:
    • AngularJS 首先会调用其提供者的生成函数,如 $get。值得注意的是,创建此依赖组件的实例时,可能会对算法内容进行递归调用,以解决该组件本身的依赖关系。
    • AngularJS 然后会将其缓存在上面提到的哈希表中。
    • AngularJS 最后会在需要的时候将其传递给对应组件。

以 AngularJS 源代码中 getService 函数的实现为例:

function getService(serviceName) {
  if (cache.hasOwnProperty(serviceName)) {
    if (cache[serviceName] === INSTANTIATING) {
      throw $injectorMinErr('cdep', 'Circular dependency found: {0}', path.join(' <- '));
    }
    return cache[serviceName];
  } else {
    try {
      path.unshift(serviceName);
      cache[serviceName] = INSTANTIATING;
      return cache[serviceName] = factory(serviceName);
    } catch (err) {
      if (cache[serviceName] === INSTANTIATING) {
        delete cache[serviceName];
      }
      throw err;
    } finally {
      path.shift();
    }
  }
}

由于每个 service 只会被实例化一次,我们可以将每个 service 看成是一个单例。缓存则可以被认为是单例管理器。这里与上面展示的 UML 图有微小的区别,那就是我们并不将单例的静态私有引用保存在其构造函数中,而是将引用保存在单例管理器中 (以上代码中的 cache)。

如此一来,service 实际还是单例,但并不是以传统单例设计模式的方法所实现。相比之下,这种方式有如下优点:

  • 增强代码的可测试性
  • 控制单例对象的创建 (在本节例子中,IoC 容器通过懒惰式单例实例化方式帮我们进行控制)

对于更深入的讨论,可以参考 Misko Hevery 在 Google Testing blog 上的文章

工厂方法模式是一种创建型模式,其实现了一个「工厂方法」概念来处理在不指定对象具体类型的情况下创建对象的问题。其解决方式不是通过构造函数来完成,而是在抽象类 (abstract class) 中定义一个用来创建对象的工厂方法,然后在实体类 (concrete classes) 中实现它。或者在一个基础类 (base class) 中实现它,而该类又可以通过继承关系被派生类 (derived class) 所重写。

Factory Method

用如下代码为例:

myModule.config(function ($provide) {
  $provide.provider('foo', function () {
    var baz = 42;
    return {
      //Factory method
      $get: function (bar) {
        var baz = bar.baz();
        return {
          baz: baz
        };
      }
    };
  });
});

在上面的代码中,我们使用 config 回调来定义一个新的「provider」。Provider 是一个对象,其中包含一个 $get 函数。由于 JavaScript 语言没有接口 (interface),而语言本身是鸭子类型 (duck-typed),所以这里提供了一个给 provider 的工厂方法进行命名的规则。

每个 service、filter、directive 和 controller 都包含一个 provider (即工厂方法名为 $get 的对象),用于负责创建该组件的实例。

让我们更深入的来看看 AngularJS 中是如何实现的:

//...

createInternalInjector(instanceCache, function(servicename) {
  var provider = providerInjector.get(servicename + providerSuffix);
  return instanceInjector.invoke(provider.$get, provider, undefined, servicename);
}, strictDi));

//...

function invoke(fn, self, locals, serviceName){
  if (typeof locals === 'string') {
    serviceName = locals;
    locals = null;
  }

  var args = [],
      $inject = annotate(fn, strictDi, serviceName),
      length, i,
      key;

  for(i = 0, length = $inject.length; i < length; i++) {
    key = $inject[i];
    if (typeof key !== 'string') {
      throw $injectorMinErr('itkn',
              'Incorrect injection token! Expected service name as string, got {0}', key);
    }
    args.push(
      locals && locals.hasOwnProperty(key)
      ? locals[key]
      : getService(key)
    );
  }
  if (!fn.$inject) {
    // this means that we must be an array.
    fn = fn[length];
  }

  return fn.apply(self, args);
}

从以上例子中,我们注意到 $get 函数被下面的代码使用:

instanceInjector.invoke(provider.$get, provider, undefined, servicename)

上面这个代码片段调用了 instanceInjectorinvoke 函数,其中第一个参数就是某 service 的工厂方法 (即 $get)。在 invoke 内部,annotate 函数又将该工厂方法作为其第一个参数。如代码所示,annotate 会使用 AngularJS 的依赖注入机制来解决所有依赖关系。当所有依赖关系都满足后,工厂方法函数就会被调用:fn.apply(self, args)

如果对照上面的 UML 图来看,我们可以认为这里的 provider 就是图中的「ConcreteCreator」,而实际组件就是「Product」。

由于工厂方法能间接的创建对象,使用这种设计模式能带来很多益处。尤其是框架能够在组件实例化的过程中关注解决一些样板化问题:

  • 选择最恰当的时机来完成组件所需的实例化过程
  • 解决组件所需的所有依赖关系
  • 设定组件所允许存在的实例个数 (对于 service 和 filter 来说只有一个,而 controller 可以有多个实例)

修饰模式又被称为 wrapper,与适配器模式的别名一样。它是一种可以动态或静态的往一个独立对象中添加新行为,而不影响同一个类所生成的其它对象的行为的设计模式。

Decorator

AngularJS 已经提供了这种方式来扩展/增强现有 service 的功能。下面通过使用 $providedecorator 函数,我们可以在第三方现有的 service 上建立一个「包装」:

myModule.controller('MainCtrl', function (foo) {
  foo.bar();
});

myModule.factory('foo', function () {
  return {
    bar: function () {
      console.log('I\'m bar');
    },
    baz: function () {
      console.log('I\'m baz');
    }
  };
});

myModule.config(function ($provide) {
  $provide.decorator('foo', function ($delegate) {
    var barBackup = $delegate.bar;
    $delegate.bar = function () {
      console.log('Decorated');
      barBackup.apply($delegate, arguments);
    };
    return $delegate;
  });
});

上述例子定义了一个名为 foo 的新 service。在 config 中,回调了 $provide.decorator 函数,其第一个参数 「foo」 就是我们想要修饰的 service 的名称,而第二个参数则是实现修饰内容的函数。$delegate 则保持引用原有 foo service。通过使用 AngularJS 的依赖注入机制,这个本地依赖的引用 (reference) 是以构造函数的第一个参数传递。在这里,我们对 service 的修饰是重写其 bar 函数。实际修饰内容只是多执行一条 console.log 语句 - console.log('Decorated');,然后继续在对应的上下文中调用原有 bar 函数。

在需要修改第三方 service 的功能时,使用这种模式特别有用。如果需要使用多个类似功能的修饰 (例如函数的性能测量,授权,日志记录等),我们可能会生成大量重复的代码,因而违反 DRY 原则。这种情况就需要使用面向侧面的程序设计 (aspect-oriented programming)。目前我所知的 AngularJS 的唯一 AOP 框架是 github.com/mgechev/angular-aop

Facade 是为大规模代码 (例如类库) 提供简化接口的对象。Facade 可以:

  1. 由于其针对常见任务有各种易用函数,可以让软件库更易于使用、理解和测试;
  1. 在某些情况下,让库更易读;
  1. 减少库的内部工作对外部代码的依赖,允许系统开发时有更大的灵活度;
  1. 可以将一些设计低劣的 API 包装到一个设计良好的 API 中。

Facade

AngularJS 中已经有很多 facade。实际上,你每次为现有功能提供更高层级的 API 时,都是在创建 facade。

例如,让我们看看如何创建一个 XMLHttpRequest POST 请求:

var http = new XMLHttpRequest(),
    url = '/example/new',
    params = encodeURIComponent(data);
http.open("POST", url, true);

http.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
http.setRequestHeader("Content-length", params.length);
http.setRequestHeader("Connection", "close");

http.onreadystatechange = function () {
  if(http.readyState == 4 && http.status == 200) {
    alert(http.responseText);
  }
}
http.send(params);

但是我们也可以用 AngularJS 的 $http service 来发送数据:

$http({
  method: 'POST',
  url: '/example/new',
  data: data
})
.then(function (response) {
  alert(response);
});

我们甚至可以用:

$http.post('/someUrl', data)
.then(function (response) {
  alert(response);
});

上面第二种方式使用了一个预先设定好的版本,用于向指定的 URL 创建并发送一个 HTTP POST 请求。

$resource 则在 $http service 之上建立了更高层级的抽象化。我们会在后面的 Active Record 模式代理模式章节中对其进行更深入的探讨。

所谓的代理者,在一般意义上,是指一个类可以作为其它东西的接口。代理者可以作任何东西的接口:网络连接、存储器中的大对象、文件或其它昂贵或无法复制的资源。

Proxy

我们可以将代理分为三种不同的类型:

  • 虚拟代理 (Virtual Proxy)
  • 远端代理 (Remote Proxy)
  • 保护代理 (Protection Proxy)

本节将讨论虚拟代理在 AngularJS 中的实现。

下面的代码会调用 get 函数,其属于一个名为 User$resource 实例 :

var User = $resource('/users/:id'),
    user = User.get({ id: 42 });
console.log(user); //{}

这里的 console.log 将输出一个空对象。原因是当 User.get 被执行时,幕后所对应的 AJAX 请求是在异步运行。当 console.log 被调用时,我们尚未获得 user 的内容。User.get 在发出 GET 请求之后,会立刻返回一个空对象,并保留指向此对象的引用。我们可以将这个对象想像成一个虚拟代理 (简单的占位器)。当客户端稍后从服务器收到响应时,再将实际数据植入此代理对象。

这在 AngularJS 中是如何工作的?让我们来看下面的代码:

function MainCtrl($scope, $resource) {
  var User = $resource('/users/:id'),
  $scope.user = User.get({ id: 42 });
}
<span ng-bind="user.name"></span>

当上面的代码最初执行时,$scope 对象内的 user 属性将被赋值为一个空对象 ({}),这意味着 user.name 的值是 undefined,网页也不会渲染任何内容。AngularJS 内部会保留一份对此对象的引用。一旦服务器返回对 get 请求的响应,AngularJS 会将来自服务器的数据植入此对象。在下一次 $digest 循环中,AngularJS 将会探测到 $scope.user 中的变化,然后更新页面。

Active Record 是一种包含数据和行为的对象。通常这些对象中的大部分数据都是持久的。Active Record 对象负责处理与数据库的交流,以实现创建、更新、接收或者删除数据。它也可能将这些任务交给更低层级的对象去完成,但数据库交流依然是通过调用 active record 对象实例或静态方法来发起。

Active Record

AngularJS 定义了一种名为 $resource 的 service。在 AngularJS 当前版本 (1.2+) 中,它是以非 AngularJS 核心的模块形式发布。

根据 AngularJS 文档,$resource 是:

一个用来创建资源对象的 factory,其功用是与 RESTful 服务器端数据源进行交互操作。其返回的资源对象中包含一些提供高层级功能的 action 方法,而无需使用低层级的 $http 服务。

$resource 可以按以下方式使用:

var User = $resource('/users/:id'),
    user = new User({
      name: 'foo',
      age : 42
    });

user.$save();

调用 $resource 将会给模型实例创建一个构造函数。每个模型实例都包含用于不同 CRUD 操作的方法函数。

如此一来,我们可以用下面的形式来使用构造函数和其静态方法:

User.get({ userid: userid });

以上代码会立即返回一个空对象并保留指向该对象的引用。当响应成功返回并解析后,AngularJS 会将所收到的数据植入该对象 (参见代理模式)。

更多有关 $resource 的内容请参阅 The magic of $resourceAngularJS 文档

由于 Martin Fowler 说过

Active Record 对象负责处理与数据库的通信,以实现...

$resource 是用于 RESTful 服务而非数据库交互,所以它并未完整的实现 Active Record 模式。但我们还是可以认为它是「类似 Active Record 的 RESTful 通信」。

创建可组合的筛选器链条来完成网页请求过程中常用的预处理和后处理任务。

Composite

在很多情况下,你需要对 HTTP 请求进行各种预处理和/或后处理工作。使用截取筛选器,你可以根据所给定的 HTTP 请求/响应的头部和正文内容来预处理或者后处理它们,以加入相应的日志、安全和其它信息。截取筛选器模式包含一个筛选器链条,并按照给定的顺序对数据进行处理。每节筛选器的输出即成为下一节的输入。

AngularJS 在 $httpProvider 中实作了截取筛选器。$httpProvider 拥有一个名为 interceptors 的数组,其中包含一组对象。每个对象都可能拥有以下属性:requestresponserequestErrorresponseError

requestError 即为一个截取器,每当之前的 request 截取器抛出错误或者被拒绝时就会调用 requestError。相应的,responseError 则是在之前的 response 截取器抛出错误时被调用。

以下是一个使用对象字面量 (object literal) 添加截取器的简单例子:

$httpProvider.interceptors.push(function($q, dependency1, dependency2) {
  return {
   'request': function(config) {
       // same as above
    },
    'response': function(response) {
       // same as above
    }
  };
});

Directives

组合模式是一种树状结构设计模式,是将一组相似的对象与一个单一的对象实例一致对待。此模式的意图是将对象组合成树形结构以表现为「部分-整体」的树形结构。

Composite

根据「四人帮」的经典论述, MVC 实际是以下部件的组合:

  • 策略模式 (Strategy)
  • 组合模式 (Composite)
  • 观察者模式 (Observer)

其中,页面即是各部件的组合。非常相似的是,AngularJS 中的页面就是由 directive 及其对应的 DOM 元素所形成的组合。

让我们来看以下例子:

<!doctype html>
<html>
  <head>
  </head>
  <body>
    <zippy title="Zippy">
      Zippy!
    </zippy>
  </body>
</html>
myModule.directive('zippy', function () {
  return {
    restrict: 'E',
    template: '<div><div class="header"></div><div class="content" ng-transclude></div></div>',
    link: function (scope, el) {
      el.find('.header').click(function () {
        el.find('.content').toggle();
      });
    }
  }
});

以上例子定义了一个简单的 directive,其功能是一个 UI 构件。此构件 (名为 "zippy") 包含头部结构和正文内容。点击其头部会切换正文部分显示或隐藏。

在第一段例子中,我们注意到整个 DOM 树就是由很多元素形成的组合。其根组件是 html 元素,紧接着是嵌套的 headbody 等等。

我们可以从第二段 JavaScript 例子看到,此 directive 的 template 属性又包含了 ng-transclude directive 标记。因此,在 zippy directive 中又存在另一个 ng-transclude directive。理论上我们可以无限的嵌套这些组件直到抵达叶节点 (leaf node)。

解释器模式是一种对计算机语言的语句进行解释估值的设计模式。其基本理念就是在该语言中,给每个终结符或者非终结符表达式赋予一个类结构。一个语句的语法树就是一个对该语句进行解释的组合模式的结构实例。

Interpreter

在 AngularJS 的 $parse service 背后,其提供了一个 DSL (领域专用语言) 语言的解释器实例。此 DSL 语言是一个精简修改版的 JavaScript。JavaScript 表达式与 AngularJS 表达式之间的主要区别是:

  • 可以包含 UNIX 类管道语法的筛选器
  • 不会抛出任何错误
  • 不含任何控制流语句 (异常、循环、条件语句,但可以使用三元运算符)
  • 在特定的上下文环境中进行解析估值 (当前 $scope 的上下文)

$parse service 中定义了两个主要的组件:

//Responsible for converting given string into tokens
var Lexer;
//Responsible for parsing the tokens and evaluating the expression
var Parser;

当给定的表达式被分词后,出于性能需求会被内部缓存。

AngularJS DSL 中的终结符表达式定义如下:

var OPERATORS = {
  /* jshint bitwise : false */
  'null':function(){return null;},
  'true':function(){return true;},
  'false':function(){return false;},
  undefined:noop,
  '+':function(self, locals, a,b){
        //...
      },
  '*':function(self, locals, a,b){return a(self, locals)*b(self, locals);},
  '/':function(self, locals, a,b){return a(self, locals)/b(self, locals);},
  '%':function(self, locals, a,b){return a(self, locals)%b(self, locals);},
  '^':function(self, locals, a,b){return a(self, locals)^b(self, locals);},
  '=':noop,
  '===':function(self, locals, a, b){return a(self, locals)===b(self, locals);},
  '!==':function(self, locals, a, b){return a(self, locals)!==b(self, locals);},
  '==':function(self, locals, a,b){return a(self, locals)==b(self, locals);},
  '!=':function(self, locals, a,b){return a(self, locals)!=b(self, locals);},
  '<':function(self, locals, a,b){return a(self, locals)<b(self, locals);},
  '>':function(self, locals, a,b){return a(self, locals)>b(self, locals);},
  '<=':function(self, locals, a,b){return a(self, locals)<=b(self, locals);},
  '>=':function(self, locals, a,b){return a(self, locals)>=b(self, locals);},
  '&&':function(self, locals, a,b){return a(self, locals)&&b(self, locals);},
  '||':function(self, locals, a,b){return a(self, locals)||b(self, locals);},
  '&':function(self, locals, a,b){return a(self, locals)&b(self, locals);},
  '|':function(self, locals, a,b){return b(self, locals)(self, locals, a(self, locals));},
  '!':function(self, locals, a){return !a(self, locals);}
};

我们可以将每个终结符所属函数看作是 AbstractExpression 接口的实现。

每个 Client 在一个特定的上下文和作用域中解释给定的 AngularJS 表达式。

以下是一个 AngularJS 表达式的例子:

// toUpperCase filter is applied to the result of the expression
// (foo) ? bar : baz
(foo) ? bar : baz | toUpperCase

模版视图指的是在 HTML 页面中嵌入标签符号以将信息渲染为 HTML 形式。

Template View

渲染动态页面并不是件简单的事情,其中包含大量字符串的连接、修改和一些难以解决的操作。创造动态页面的最简单的方法是编写自己的标记符号并在其中嵌入表达式。稍后在给定的上下文中对这些表达式进行解析,从而将整个模版编译成其最终格式,此处即为 HTML (或 DOM) 格式。这就是模版引擎的功用 - 提取指定的 DSL,在恰当的上下文中进行解析,并转换成其最终格式。

模版是一种常用技术,尤其是在后端应用中。例如,你可以将 PHP 代码嵌入到 HTML 中形成动态页面,也可以使用 Smarty 模版引擎,或者还可以使用 eRuby 将 Ruby 代码嵌入静态页面中。

适用于 JavaScript 的模版引擎有很多,例如 mustache.js、handlebars 等。大部分引擎是将模版以字符串的方式进行操作。模版可以存放在不同的位置:一是静态文件,其可以通过 AJAX 方式获取;二是以 script 形式嵌在视图中;或甚至是内嵌在 JavaScript 中。

例如:

<script type="template/mustache">
  <h2>Names</h2>
  {{#names}}
    <strong>{{name}}</strong>
  {{/names}}
</script>

模版引擎在一个给定的上下文中编译此字符串,将其转换为 DOM 元素。如此一来,所有嵌在标记符中的表达式都会被解析,并替换成其计算值。

例如,如果以 { names: ['foo', 'bar', 'baz'] } 对象为上下文对上面的模版进行解析,我们可以得到:

<h2>Names</h2>
  <strong>foo</strong>
  <strong>bar</strong>
  <strong>baz</strong>

AngularJS 模版实际就是 HTML,而非其他传统模版所使用的中间层格式。AngularJS 编译器会遍历 DOM 树并搜索已知的 directive (适用于元素、属性、类或甚至注释)。当 AngularJS 找到任何 directive,就会调用其所属的逻辑代码,在当前作用域的上下文中解析其中的表达式。

例如:

<ul ng-repeat="name in names">
  <li>{{name}}</li>
</ul>

在下列作用域的上下文中:

$scope.names = ['foo', 'bar', 'baz'];

会生成跟上面相同的结果。其主要区别是,模版是包装在 HTML 中,而非 script 标签之间。

Scope

观察者模式是软件设计模式的一种。在此种模式中,一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知。这通常透过呼叫各观察者所提供的方法来实现。此种模式通常被用来实作事件处理系统。

Observer

AngularJS 应用中 scope 之间有两种互相通信的方式。其一是在子 scope 中调用父 scope 的函数方法,这是基于子 scope 与其父母的原型继承关系 (参见 Scope)。这种方式允许一种的单向通信 - 从子到父。当然有些时候也需要在父 scope 的上下文中调用子 scope 的函数方法或者通知其某个触发事件。对于此需求,AngularJS 内置了一种观察者模式的实现。观察者模式的另一个使用场景是,如果多个 scope 都关注某个事件,但该事点件触发所处上下文对应的 scope 与其它这些 scope 并无联系。这可以切断不同 scope 之间的耦合关系,形成独立的存在。

每个 AngularJS scope 都含有几个公有函数:$on$emit$broadcast$on 函数接受事件主题为第一参数,事件回调函数做为第二参数。我们可以将回调函数看作为一个观察者,即实作 Observer 接口的对象 (因为JavaScript 的头等函数特性,所以我们只需提供 notify 函数方法的实现)。

function ExampleCtrl($scope) {
  $scope.$on('event-name', function handler() {
    //body
  });
}

在这种方式中,当前 scope 会「订阅」类别为 event-name 的事件。当 event-name 在任何父 scope 或子 scope 中被触发后,handler 将被调用。

$emit$broadcast 函数则分别被用于在 scope 链中向上或向下触发事件。例如:

function ExampleCtrl($scope) {
  $scope.$emit('event-name', { foo: 'bar' });
}

以上例子中的 scope 会向上方的 scope 触发 event-name 事件。意思是所有订阅了 event-name 事件的的父 scope 都会得到通知并执行其 handler 回调函数。

$broadcast 函数调用与此类似。唯一的区别是事件是向下传递给所有子 scope。每个 scope 可以给任何事件订阅配属多个回调函数 (即,一个给定事件对应多个观察者)。

在 JavaScript 社群中,这种模式又被称为发布/订阅模式。

更好的实战例子请参见观察者模式作为外部服务章节。

责任链模式在面向对象程式设计里是一种软件设计模式,它包含了一些命令对象和一系列的处理对象。每一个处理对象决定它能处理哪些命令对象,它也知道如何将它不能处理的命令对象传递给该链中的下一个处理对象。该模式还描述了往该处理链的末尾添加新的处理对象的方法。

Chain of Responsibilities

AngularJs 应用中的 scope 组成了一个层级结构,称为 scope 链。其中有些 scope 是「独立的」,指的是它们并不从其父 scope 进行原型继承,而是通过 $parent 属性进行连接。

当调用 $emit 或者 $broadcast 时,我们可以将 scope 链看作事件传递总线,或更精确的说就是责任链。每当某个事件被触发时,无论是向上还是向下传递 (基于调用不同的函数方法),链条中紧随而后的 scope 可能会进行如下操作:

  • 处理该事件并传递给链条中的下一环
  • 处理该事件并终止传递
  • 不处理此事件,而直接将事件传递给下一环
  • 不处理此事件,并终止传递

从下面的例子中可以看到 ChildCtrl 触发了一个在 scope 链条中向上传递的事件。每个父 scope (ParentCtrlMainCtrl 中的 scope) 将处理此事件,即在 console 中输出记录 "foo received"。如果某个 scope 被认为是最终目标,则可以在此处调用事件对象 (即回调函数中所传递的参数) 的 stopPropagation 方法。

myModule.controller('MainCtrl', function ($scope) {
  $scope.$on('foo', function () {
    console.log('foo received');
  });
});

myModule.controller('ParentCtrl', function ($scope) {
  $scope.$on('foo', function (e) {
    console.log('foo received');
  });
});

myModule.controller('ChildCtrl', function ($scope) {
  $scope.$emit('foo');
});

上面这些被注入到 controller 中的 scope 即为稍早提到的 UML 图中的各个 handler 。

在面向对象程式设计的范畴中,命令模式是一种行为设计模式,它尝试以物件来代表并封装所有用于在稍后某个时间调用一个函数方法所需要的信息。此信息包括所要调用的函数的名称、宿主对象及其参数值。

Command

在继续讨论命令模式的应用之前,让我们来看看 AngularJS 是如何实现数据绑定的。

当我们需要将模型与视图绑定时,可以使用 ng-bind (单向数据绑定) 或 ng-model (双向数据绑定) directive。例如,下面的代码可以将 foo 模型中的每个变化反映到视图中:

<span ng-bind="foo"></span>

每当我们改变 foo 的值时,span 中的 inner text 就会随之改变。我们可以使用 AngularJS 表达式来实现更复杂的的同类效果,例如:

<span ng-bind="foo + ' ' + bar | uppercase"></span>

上面的例子中,span 内的文本值等于 foobar 的值串联后再转为大写字母。让我们来看看幕后都发生了什么事?

每个 $scope 都含有名为 $watch 的函数方法。当 AngularJS 编译器找到 ng-bind directive 时,就会为 foo + ' ' + bar | uppercase 表达式创建一个新的监视器,即 $scope.$watch("foo + ' ' + bar | uppercase", function () { /* body */ });。每当表达式的值改变时,监视器中的回调函数就会被触发。在本例中,回调将会更新 span 的文本。

下面是 $watch 函数的头几行:

$watch: function(watchExp, listener, objectEquality) {
  var scope = this,
      get = compileToFn(watchExp, 'watch'),
      array = scope.$$watchers,
      watcher = {
        fn: listener,
        last: initWatchVal,
        get: get,
        exp: watchExp,
        eq: !!objectEquality
      };
//...

我们可以将 watcher 对象视为一条命令。该命令的表达式会在每次 "$digest" 循环中被估值。一旦 AngularJS 发现表达式中的数值改变,就会调用 listener 函数。watcher 命令中封装了用以完成以下任务所需的全部信息。任务包括监视所给定的表达式和将命令执行委托给 listener 函数 (实际接收者) 。我们可以将 $scope 视为命令的 Client (客户),将 $digest 循环视为命令的 Invoker (执行者)。

Controllers

页面控制器指的是用于处理网站上某个特定页面请求或操作的对象。 Martin Fowler

Page Controller

根据参考文献4

页面控制器模式接受来自页面请求的输入、对具体模型调用所要求的操作,并决定在结果页面所应该使用的正确视图。将调度逻辑从视图相关代码中分离。

由于不同的页面之间存在大量重复操作 (例如渲染页脚、页眉,处理用户会话等),各个页面控制器在一起可以构成一个层级结构。AngularJS 中的控制器 (controller) 有着相对较有限的用途。它们并不接受用户请求,那是 $route$state service 的任务,页面渲染则是 ng-viewui-view directive 的责任。

与页面控制器类似,AngularJS 的 controller 负责处理用户交互操作,提供并更新模型。当模型 (model) 附着于 scope 上,它就被暴露给页面 (view) 所使用。基于用户的操作行为,页面会调用已经附着于 scope 上的相应函数方法。页面控制器与 AngularJS controller 的另一个相似点则是由它们所组成的层级结构。此结构于 scope 层级相对应。由此,共通的操作可以被隔离出来置于基础 controller 中。

AngularJS 中的 controller 与 ASP.NET WebForms 背后的代码非常相似,它们有着几乎相同的功用。以下是 controller 层级结构例子:

<!doctype html>
<html>
  <head>
  </head>
  <body ng-controller="MainCtrl">
    <div ng-controller="ChildCtrl">
      <span>{{user.name}}</span>
      <button ng-click="click()">Click</button>
    </div>
  </body>
</html>
function MainCtrl($scope, $location, User) {
  if (!User.isAuthenticated()) {
    $location.path('/unauthenticated');
  }
}

function ChildCtrl($scope, User) {
  $scope.click = function () {
    alert('You clicked me!');
  };
  $scope.user = User.get(0);
}

此例子描述了一个最简单的通过使用基础控制器来实现重用逻辑的例子。当然,我们不推荐在生产应用中将授权逻辑置于控制器中。不同路径 (route) 的权限可以在更高的抽象层级来判定。

ChildCtrl 负责处理点击 "Click" 按钮之类的操作,并将模型附着于 scope 上,使其暴露给页面使用。

模块模式并非「四人帮」所提出的设计模式之一,也不是来自《企业应用架构模式》。它是一种传统的 JavaScript 模式,主要用来提供封装和私密特性。

通过使用模块模式,你可以基于 JavaScript 的函数作用域来实现程序结构的私密性。每个模块可以拥有零至多个私有成员,这些成员都隐藏在函数的本地作用域中。此函数会返回一个对象,用于输出给定模块的公有 API:

var Page = (function () {

  var title;

  function setTitle(t) {
    document.title = t;
    title = t;
  }

  function getTitle() {
    return title;
  }

  return {
    setTitle: setTitle,
    getTitle: getTitle
  };
}());

上面的例子中,我们实作了一个 IIFE (立即执行函数表达式)。当它被调用后会返回一个拥有两个函数方法 (setTitlegetTitle) 的对象。此对象又被赋值给 Page 变量。

在此处,使用 Page 对象并不能直接修改在 IIFE 本地作用域内部所定义的 title 变量。

模块模式在定义 AngularJS 中的 service 时非常有用。使用此模式可以模拟 (并实现) 私密特性:

app.factory('foo', function () {

  function privateMember() {
    //body...
  }

  function publicMember() {
    //body...
    privateMember();
    //body
  }

  return {
    publicMember: publicMember
  };
});

foo 被注入到任何其他组件中时,我们并不能使用其私有函数方法,而只能使用公有方法。这种解决方案在搭建可重用的库时极为有用。

数据映射器指的是在持久化数据存储 (通常是关系型数据库) 与内存数据表述 (domain layer) 之间执行双向传输的数据存取层。此模式的目的是保持内存中的数据表述和持久化数据存储相互独立,以及数据映射器本身的独立性。

Data Mapper

根据以上表述,数据映射器是用来在持久化数据存储和内存中的数据表述之间进行双向数据传输。AngularJS 应用通常是与 API 服务器进行数据交流。此服务器是用某种服务器端语言 (Ruby、PHP、Java、JavaScript 等) 实现.

一般来说,如果服务器端提供 RESTful API,$resource 可以帮助我们以 Active Record 类的方式与服务器通讯。尽管在某些应用中,从服务器返回的数据并非是最适合于在前端使用的格式。

例如,让我们假设某个应用中,每个用户包含:

  • name
  • address
  • list of friends

并且 API 提供了以下方法:

  • GET /user/:id - 返回指定用户的用户名和地址
  • GET /friends/:id - 返回指定用户的好友列表

一种可能的解决方案是使用两个不同的服务,分别用于以上两个方法。另一个更好的方案是,提供一个名为 User 的 service,它会在请求某个用户时同时加载该用户的好友。

app.factory('User', function ($q) {

  function User(name, address, friends) {
    this.name = name;
    this.address = address;
    this.friends = friends;
  }

  User.get = function (params) {
    var user = $http.get('/user/' + params.id),
        friends = $http.get('/friends/' + params.id);
    $q.all([user, friends])
    .then(function (user, friends) {
      return new User(user.name, user.address, friends);
    });
  };
  return User;
});

如此一来,我们就创建了一个伪数据映射器,用来使我们的 API 适应 SPA (单页应用程序) 的需求。

我们可以通过下方式使用 User 服务:

function MainCtrl($scope, User) {
  User.get({ id: 1 })
  .then(function (data) {
    $scope.user = data;
  });
}

以及如下模版片段:

<div>
  <div>
    Name: {{user.name}}
  </div>
  <div>
    Address: {{user.address}}
  </div>
  <div>
    Friends with ids:
    <ul>
      <li ng-repeat="friend in user.friends">{{friend}}</li>
    </ul>
  </div>
</div>
关于

以下是一个取自此处的例子。这是一个 AngularJS factory,它实作了一个观察者模式的 service。它很适用于 ControllerAs 方法,如果正确使用的话,它比 $scope.$watch 运行更有效率,相比于 $emit$broadcast,它更明确的对应唯一的 scope 或对象。

**用例:**你可以通过此模式在两个使用同一模型但互不关联的控制器之间通讯。

控制器实例

以下例子展示了如何添附 (attach)、通知 (notify) 以及解附 (detach) 一个事件。

angular.module('app.controllers')
  .controller('ObserverExample', ObserverExample);
ObserverExample.$inject= ['ObserverService', '$timeout'];

function ObserverExample(ObserverService, $timeout) {
  var vm = this;
  var id = 'vm1';

  ObserverService.attach(callbackFunction, 'let_me_know', id)

  function callbackFunction(params){
    console.log('now i know');
    ObserverService.detachByEvent('let_me_know')
  }

  $timeout(function(){
    ObserverService.notify('let_me_know');
  }, 5000);
}

另一种移除事件的方式

angular.module('app.controllers')
  .controller('ObserverExample', ObserverExample);
ObserverExample.$inject= ['ObserverService', '$timeout', '$scope'];

function ObserverExample(ObserverService, $timeout, $scope) {
  var vm = this;
  var id = 'vm1';
  ObserverService.attach(callbackFunction, 'let_me_know', id)

  function callbackFunction(params){
    console.log('now i know');
  }

  $timeout(function(){
    ObserverService.notify('let_me_know');
  }, 5000);

  // Cleanup listeners when this controller is destroyed
  $scope.$on('$destroy', function handler() {
    ObserverService.detachByEvent('let_me_know')
  });
}
  1. 维基百科 本文所有设计模式的简介都引自维基百科。
  2. AngularJS 文档
  3. AngularJS 源码库
  4. 页面控制器 (Page Controller)
  5. 企业应用架构模式 (P of EAA)
  6. Using Dependancy Injection to Avoid Singletons
  7. Why would one use the Publish/Subscribe pattern (in JS/jQuery)?