使用 AngularJS 指令扩展 HTML






4.94/5 (49投票s)
使用 AngularJS 指令为 HTML 增添新功能。
AngularJS 简介
AngularJS 是 Google 用于开发 Web 应用程序的框架。Angular 提供了许多协同工作良好且设计为可扩展的基本服务。这些服务包括数据绑定、DOM 操作、路由/视图管理、模块加载等等。
AngularJS 不仅仅是又一个库。它提供了一个完整的集成框架,因此减少了您需要处理的库的数量。它来自 Google,也就是构建了 Chrome 浏览器并正在帮助为下一代 Web 应用程序奠定基础的同一群人(更多信息请查看 polymer 项目:www.polymer-project.org/)。我相信在五年或十年后,我们将不再使用 AngularJS 开发 Web 应用,但我们会使用类似的东西。
对我来说,AngularJS 最令人兴奋的特性是编写自定义指令的能力。自定义指令允许您使用新的标签和属性来扩展 HTML。指令可以在项目内部和跨项目重用,并且大致相当于像 .NET 这样的平台中的自定义控件。
本文附带的示例包含了近 50 个基于 Bootstrap、Google JavaScript API 和 Wijmo 创建的自定义指令。该示例有完整的注释和文档,因此当您开始编写自己的指令时,它应该能成为一个很好的参考。您可以在这里看到示例的实时演示:http://demo.componentone.com/wijmo/Angular/AngularExplorer/AngularExplorer
创建适合您需求的指令相当容易。这些指令可以被测试、维护并在多个项目中重用。正确实现的指令可以被增强和重新部署,而对使用它们的应用程序几乎没有或完全没有改变。
本文档重点介绍 AngularJS 指令,但在进入该主题之前,我们将快速回顾一些 AngularJS 的基础知识以提供上下文。
要使用 AngularJS,您必须在 HTML 页面中将其作为引用包含进来,并在页面的 HTML 或 body 标签上添加一个 ng-app
属性。这里有一个非常简短的示例来展示它是如何工作的
<html>
<head>
<script src="http://code.angularjs.org/angular-1.0.1.js"></script>
</head>
<body ng-app ng-init="msg = 'hello world'">
<input ng-model="msg" />
<p>{{msg}}</p>
</body>
</html>
当 AngularJS 加载时,它会扫描文档以查找 ng-app
属性。这个标签通常设置为应用程序主模块的名称。一旦找到 ng-app
属性,Angular 将处理该文档,加载主模块及其依赖项,扫描文档中的自定义指令等等。
在这个例子中,ng-init
属性将一个 msg
变量初始化为 "hello world",而 ng-model
属性将该变量的内容绑定到一个 input 元素。用花括号括起来的文本是一个绑定表达式。AngularJS 会计算该表达式,并在表达式的值发生变化时更新文档。您可以在这里看到它的实际效果:jsfiddle.net/Wijmo/HvSQQ/
AngularJS 模块
模块对象是 AngularJS 应用程序的根。它们包含诸如 config
、controller
、factory
、filter
、directive
以及其他一些对象。
如果您熟悉 .NET 但对 Angular 不熟悉,下表展示了一个粗略的类比,有助于解释每种 AngularJS 对象所扮演的角色
AngularJS | .NET | 注释 |
module | 程序集 | 应用程序构建块 |
控制器 (controller) | ViewModel | 包含应用程序逻辑并将其暴露给视图 |
作用域 (scope) | 数据上下文 (DataContext) | 提供可绑定到视图元素的数据 |
过滤器 (filter) | 值转换器 (ValueConverter) | 在数据到达视图之前对其进行修改 |
指令 (directive) | 组件 (Component) | 可重用的 UI 元素 |
工厂 (factory)、服务 (service) | 工具类 | 为其他模块元素提供服务 |
例如,这段代码创建了一个包含一个控制器、一个过滤器和一个指令的模块
var myApp = angular.module("myApp", []);
myApp.controller("myCtrl", function($scope) {
$scope.msg = "hello world";
});
myApp.filter("myUpperFilter", function() {
return function(input) {
return input.toUpperCase();
}
});
myApp.directive("myDctv", function() {
return function(scope, element, attrs) {
element.bind("mouseenter", function() {
element.css("background", "yellow");
});
element.bind("mouseleave", function() {
element.css("background", "none");
});
}
});
module
方法接受模块名称和依赖项列表作为参数。在这个例子中,我们创建的模块不依赖于任何其他模块,所以列表是空的。请注意,即使数组是空的,也必须指定。省略它会导致 AngularJS 检索之前指定的同名模块。我们将在下一节更详细地讨论这一点。
controller
的构造函数会得到一个 $scope
对象,该对象负责持有控制器暴露的所有属性和方法。这个作用域将由 Angular 管理,并传递给视图和指令。在这个例子中,控制器向作用域添加了一个 msg
属性。一个应用程序模块可以有多个控制器,每个控制器负责一个或多个视图。控制器不必是模块的成员,但这样做是一种好的实践。
filter
的构造函数返回一个函数,该函数将用于修改输入以供显示。Angular 提供了几个过滤器,但您可以添加自己的过滤器,并以完全相同的方式使用它们。在这个例子中,我们定义了一个将字符串转换为大写的过滤器。过滤器不仅可以用来格式化值,还可以用来修改数组。AngularJS 提供的格式化过滤器包括 number
、date
、currency
、uppercase
和 lowercase
。数组过滤器包括 filter
、orderBy
和 limitTo
。过滤器可以接受参数,语法始终相同:someValue | filterName:filterParameter1:filterParameter2...。
directive
的构造函数返回一个函数,该函数接受一个元素并根据作用域中定义的参数对其进行修改。在这个例子中,我们为 mouseenter
和 mouseleave
事件绑定了事件处理程序,以便在鼠标悬停在元素上时高亮显示其内容。这是我们的第一个指令,仅仅触及了指令能做什么的皮毛。AngularJS 指令可以用作属性或元素(甚至是注释),它们可以嵌套并相互通信。我们将在后面的章节中详细介绍这些内容。
这是一个使用该模块的页面
<body ng-app="myApp" ng-controller="myCtrl">
<input ng-model="msg" />
<p my-dctv >
{{ msg | myUpperFilter }}
</p>
</body>
您可以在这里看到它的实际效果:jsfiddle.net/Wijmo/JKBbV/
请注意,应用模块、控制器和过滤器的名称被用作属性的值
。它们代表 JavaScript 对象,因此这些名称是区分大小写的。
另一方面,指令的名称被用作属性的名称
。它代表一个 HTML 元素,因此不区分大小写。然而,AngularJS 会将驼峰命名法的指令名称转换为用连字符分隔的字符串。因此,“myDctv”指令变成了“my-dctv”(就像内置指令 ngApp
、ngController
和 ngModel
变成了“ng-app”、“ng-controller”和“ng-model”一样)。
项目组织
AngularJS 被设计用于处理大型项目。您可以将项目分解为多个模块,将模块拆分成多个文件,并以任何对您有意义的方式组织这些文件。我见过的大多数项目都倾向于遵循 Brian Ford 在他的博客 使用 AngularJS 构建超大型应用 中建议的惯例。其基本思想是将模块分解到文件中,并按类型对它们进行分组。因此,控制器放在 controllers 文件夹中(并命名为 XXXCtrl
),指令放在 directives 文件夹中(并命名为 XXXDctv
),等等。
一个典型的项目文件夹可能看起来像这样
Root
default.html
styles
app.css
partials
home.html
product.html
store.html
scripts
app.js
controllers
productCtrl.js
storeCtrl.js
directives
gridDctv.js
chartDctv.js
filters
formatFilter.js
services
dataSvc.js
vendor
angular.js
angular.min.js
例如,假设您想使用一个定义在 app.js 文件中的单个模块。您可以这样定义它
// app.js
angular.module("appModule", []);
要向模块中添加元素,您需要按名称请求模块,然后像我们之前展示的那样向其添加元素。例如,formatFilter.js 文件将包含类似这样的内容
// formatFilter.js
// retrieve module by name
var app = angular.module("appModule");
// add a filter to the module
app.filter("formatFilter", function() {
return function(input, format) {
return Globalize.format(input, format);
}
}})
如果您的应用程序包含多个模块,请记住在创建每个模块时指定依赖关系。例如,一个包含名为 app
、controls
和 data
的三个模块的应用程序可以如下指定它们
// app.js (the main application module, depends on "controls" and "data" modules)
angular.module("app", [ "controls", "data"])
// controls.js (the controls module, depends on "data" module)
angular.module("controls", [ "data" ])
// data.js (the data module, no dependencies)
angular.module("data", [])
您应用程序中的主页面将在 ng-app
指令中指定主模块的名称,AngularJS 将自动引入所有必需的依赖项
<html ng-app="app">
...
</html>
然后,主页面及其所有视图将能够使用定义在这三个模块中的元素。
关于一个按照上述方式组织起来的相当大的应用程序的例子,请参阅本文附带的 AngularExplorer 示例。
现在我们已经介绍了 AngularJS 的基础知识,是时候来处理我们的主要议题:指令了。在接下来的几章中,我们将介绍基本概念,并创建相当多的指令来展示它们的可能性,这些可能性是相当惊人的。
如果您想在继续之前(或者任何时候)多了解一些关于 AngularJS 的知识,我推荐 Dan Wahling 的优秀视频“ AngularJS 基础知识 60 分钟速成”。在“ 关于那些指令”页面上,也有一些由 AngularJS 团队成员制作的有趣视频。
AngularJS 指令:为什么?
我之前说过,对我而言,指令是 AngularJS 最令人兴奋的特性。这是因为它们是 AngularJS 真正独一无二的特性。尽管 AngularJS 中的其他特性也很棒,但在其他框架中也能找到。但是,创建可重用的组件库,并能以纯 HTML 的方式添加到应用程序中的能力,是非常强大的,据我所知,AngularJS 是目前唯一为 Web 应用程序提供这种能力的框架。
有几种 JavaScript 产品为 Web 开发者提供控件。例如,Bootstrap 是一个流行的“前端框架”,提供样式和一些 JavaScript 组件。问题在于,为了使用这些组件,HTML 作者必须切换到 JavaScript 模式并编写 jQuery 代码来激活选项卡。jQuery 代码足够简单,但它必须与 HTML 同步,这是一个繁琐且容易出错的过程,扩展性也不好。
AngularJS 主页展示了一个简单的指令,它封装了 Bootstrap 的选项卡组件,使得在纯 HTML 中使用它变得非常容易。该指令让选项卡的使用像有序列表一样简单。此外,该指令可以在许多项目中被许多 HTML 开发者重用。HTML 就像这样简单:
<body ng-app="components">
<h3>BootStrap Tab Component</h3>
<tabs>
<pane title="First Tab">
<div>This is the content of the first tab.</div>
</pane>
<pane title="Second Tab">
<div>This is the content of the second tab.</div>
</pane>
</tabs>
</body>
您可以在这里看到它的实际效果:jsfiddle.net/Wijmo/ywUYQ/
如您所见,这个页面看起来像常规的 HTML,只不过它通过作为指令实现的 <tabs>
和 <pane>
标签进行了扩展。HTML 开发者不需要编写任何 JavaScript。当然,有人需要编写这些指令,但这些指令是通用的。它们可以编写一次并重用多次(就像 BootStrap、jQueryUI、Wijmo 以及所有其他优秀的库一样)。
因为指令非常有用,而且编写起来并不那么难,所以许多人已经在为流行的库创建指令。例如,AngularJS 团队为 Bootstrap 创建了一套名为 UI Bootstrap 的指令;ComponentOne 在其 Wijmo 库中附带了 AngularJS 指令;还有几个公共的指令库,用于 jQueryUI 小部件。
但是等等!如果有这么多现成的指令来源,为什么你还要学习如何自己创建它们呢?好问题。也许你不需要。所以在自己动手写之前,先四处看看。但有几个学习的好理由
- 您可能有特殊需求。例如,假设您在一家金融公司工作,该公司在许多应用程序中使用某种类型的表单。该表单可以实现为一个数据网格,具有以特定方式下载数据、以特定方式编辑和验证数据以及以特定方式将更改上传回服务器的自定义功能。公司外的任何人不太可能有对您有用的东西。但是您可以编写一个自定义指令,并将其提供给团队中的所有 HTML 开发人员,让他们可以这样写
- 也许你想要的指令真的还不存在。也许你碰巧喜欢一个还没有人为其编写指令的库,而你又不想等待。或者也许你只是不喜欢你找到的指令,并想对它们进行调整。
<body ng-app="abcFinance">
<h3>Offshore Investment Summary</h3>
<abc-investment-form
customer="currentCustomer"
country="currentCountry">
</abc-investment-form data>
</body>
“abcInvestmentForm
”指令可以在许多应用程序中使用,提供一致性。该指令将进行集中维护,并且可以更新以反映新的业务实践或要求,而对应用程序的影响很小。
好吧,我想如果你正在阅读这篇文章,你已经对指令的想法深信不疑,并渴望开始。那么让我们继续吧。
AngularJS 指令:如何实现?
我们在文章开头展示的指令非常简单。它只指定了一个“link”函数,没有别的了。一个典型的指令包含更多元素
// create directive module (or retrieve existing module)
var m = angular.module("myApp");
// create the "my-dir" directive
myApp.directive("myDir", function() {
return {
restrict: "E", // directive is an Element (not Attribute)
scope: { // set up directive's isolated scope
name: "@", // name var passed by value (string, one-way)
amount: "=", // amount var passed by reference (two-way)
save: "&" // save action
},
template: // replacement HTML (can use our scope vars here)
"<div>" +
" {{name}}: <input ng-model='amount' />" +
" <button ng-click='save()'>Save</button>" +
"</div>",
replace: true, // replace original markup with template
transclude: false, // do not copy original HTML content
controller: [ "$scope", function ($scope) { … }],
link: function (scope, element, attrs, controller) {…}
}
});
请注意指令名称如何遵循一种模式:“my”前缀类似于命名空间,因此如果应用程序使用来自多个模块的指令,将很容易确定它们的定义位置。这不是强制要求,但这是一个推荐的实践,非常有意义。
指令构造函数返回一个具有多个属性的对象。这些属性都在 AngularJS 网站上有文档记录,但他们提供的解释并不总是那么清晰。所以,这里是我对这些属性作用的解释
restrict
:决定指令将如何在 HTML 中使用。有效选项是 "A"、"E"、"C" 和 "M",分别代表属性 (attribute)
、元素 (element)
、类 (class)
或注释 (comment)
。默认值是 "A",即属性。但我们更感兴趣的是元素,因为这是您创建 UI 元素(如前面展示的“tab”指令)的方式。scope
:创建一个属于指令的独立作用域,将其与调用者的作用域隔离开来。作用域变量作为指令标签中的属性传入。在创建可重用组件时,这种隔离至关重要,因为这些组件不应依赖于父作用域。scope
对象定义了作用域变量的名称和类型。上面的例子定义了三个作用域变量name: "@" (按值传递,单向)
:
@ 符号 "@" 表示此变量是按值传递的。指令接收一个字符串,该字符串包含从父作用域传入的值。指令可以使用它,但不能更改父作用域中的值(它是隔离的)。这是最常见的变量类型。amount: "=" (按引用传递,双向)
等号“=”表示此变量通过引用传递。指令接收到主作用域中值的引用。该值可以是任何类型,包括复杂对象和数组。指令可以更改父作用域中的值。当指令需要更改父作用域中的值(例如编辑器控件)、当值是无法序列化为字符串的复杂类型,或者当值是序列化为字符串会很耗费性能的大型数组时,会使用此类型的变量。save: "&" (表达式)
“&”符号表示该变量持有一个在父作用域上下文中执行的表达式。它允许指令执行除了简单更改值之外的其他操作。template
:替换原始标记中元素的字符串。替换过程会将所有属性从旧元素迁移到新元素。请注意模板如何使用在隔离作用域中定义的变量。这允许您编写不需要任何额外代码的宏式指令。然而,在大多数情况下,模板只是一个空的<div>
,将使用下面讨论的link
函数中的代码来填充。replace
:决定指令模板是应该替换原始标记中的元素,还是附加到它上面。默认值为 false,这会导致原始标记被保留。transclude
:决定自定义指令是否应复制原始标记中的内容。例如,前面展示的“tab”指令将transclude
设置为 true,因为 tab 元素包含其他 HTML 元素。而“dateInput”指令则没有 HTML 内容,因此您可以将transclude
设置为 false(或者直接省略它)。link
:这个函数包含了指令的大部分逻辑。它负责执行 DOM 操作、注册事件监听器等。link
函数接受以下参数scope
:对指令隔离作用域的引用。scope
变量最初是未定义的,link
函数会注册监视器(watch)以接收其值变化的通知。element
:对包含指令的 DOM 元素的引用。link
函数通常使用 jQuery(或者如果未加载 jQuery,则使用 Angular 的 jqLite)来操作此元素。controller
:用于嵌套指令的场景。此参数为子指令提供对父指令的引用,从而允许指令之间进行通信。前面讨论的 tab 指令就是一个很好的例子:jsfiddle.net/Wijmo/ywUYQ/
请注意,当 link
函数被调用时,通过值传递(“@”)的作用域变量尚未被初始化。它们将在指令生命周期的稍后阶段被初始化,如果您想接收通知,则必须使用下一节讨论的 scope.$watch
函数。
如果你还不熟悉指令,真正理解这一切的最好方法是动手玩一些代码,尝试不同的东西。这个 fiddle 可以让你做到这一点:jsfiddle.net/Wijmo/LyJ2T/
这个 fiddle 定义了一个包含三个成员(customerName
、credit
和 save
)的控制器。它还定义了一个与上面列出的类似的指令,该指令有一个包含三个成员(name
、amount
和 save
)的隔离作用域。HTML 展示了你如何在纯 HTML 中以及通过指令来使用控制器。尝试更改标记、隔离变量的类型、模板等等。这应该能让你对指令的工作方式有一个很好的了解。
指令与父作用域之间的通信
好的,所以指令应该有自己独立的隔离作用域,这样它们才能在不同的项目中重复使用,并绑定到不同的父作用域。但这些作用域究竟是如何通信的呢?
例如,假设您有一个指令,其隔离作用域声明如上例所示
scope: { // set up directive's isolated scope
name: "@", // name var passed by value (string, one-way)
amount: "=", // amount var passed by reference (two-way)
save: "&" // save command
},
并假设指令在此上下文中被使用
<my-dir
name="{{customerName}}"
amount="customerCredit"
save="saveCustomer()"
/>
注意“name”属性是如何用花括号括起来的,而“amount”则没有。这是因为“name”是按值传递的。如果没有括号,该值将被设置为字符串“customerName”。括号会使 AngularJS 在设置属性值之前先计算表达式。相比之下,“amount”是一个引用,所以你不需要括号。
指令可以通过简单地从 scope
对象中读取来检索作用域变量的值
var name = scope.name;
var amount = scope.amount;
这确实会返回变量的当前值,但如果父作用域中的值发生变化,指令将不会知道。为了收到这些变化的通知,它必须为这些表达式添加观察者(watcher)。这可以通过 scope.$watch
方法来完成,其定义如下:
scope.$watch(watchExpression, listenerFunction, objectEquality);
watchExpression
是您想要监视的东西(在我们的例子中是 "name" 和 "amount")。listenerFunction
是当表达式的值发生变化时被调用的函数。这个函数负责更新指令以反映新值。
最后一个参数 objectEquality
决定了 AngularJS 应该如何比较变量的新旧值。如果将 objectEquality
设置为 true,那么 AngularJS 将对新旧值进行深度比较,而不是简单的引用比较。当作用域变量是引用(“=”)而不是值(“@”)时,这一点非常重要。例如,如果变量是一个数组或一个复杂对象,将 objectEquality
设置为 true 将导致即使变量仍然引用同一个数组或对象,但数组或对象的内容发生了变化时,listenerFunction
也会被调用。
回到我们的例子,你可以使用这段代码来监视作用域变量的变化
scope.$watch("name", function(newValue, oldValue, srcScope) {
// handle the new value of "name"
});
scope.$watch("amount", function(newValue, oldValue, srcScope) {
// handle the new value of "amount"
});
请注意,listenerFunction
会接收到新值和旧值,以及作用域对象本身。你很少会需要这些参数,因为新值已经设置在作用域上了,但在某些情况下,你可能想确切地检查发生了什么变化。在一些罕见的情况下,新值和旧值实际上可能相同。这可能在指令初始化时发生。
那么反方向呢?在我们的例子中,“amount”变量是一个值的引用,父作用域可能也在以同样的方式监视它的变化。
在大多数情况下,你什么都不用做。AngularJS 会自动检测因用户交互而发生的变化,并为你处理所有的监视器。但情况并非总是如此。因浏览器 DOM 事件、setTimeout
、XHR 或第三方库而发生的变化不会被 Angular 检测到。在这些情况下,你应该调用 scope.$apply
方法,它会将变化广播给所有注册的监听器。
例如,假设我们的指令有一个名为 updateAmount
的方法,它执行一些计算并更改“amount”属性的值。你可以这样实现它
function updateAmount() {
// update the amount value
scope.amount = scope.amount * 1.12;
// inform listeners of the change
if (!scope.$$phase) scope.$apply("amount");
}
scope.$$phase
变量是在 AngularJS 更新作用域变量时设置的。我们测试这个变量是为了避免在更新周期内调用 $apply
。
总结一下,scope.$watch
处理入站的变化通知,而 scope.$apply
处理出站的变化通知(但你很少需要调用它)。
像往常一样,真正理解某件事的最好方法是观察它的实际运作。在 jsfiddle.net/Wijmo/aX7PY/ 的 fiddle 中定义了一个控制器和一个指令。两者都有改变数组中数据的方法,并且都监听彼此应用的变化。尝试注释掉对 scope.$watch
和 scope.$apply
的调用,看看它们的效果。
共享代码/依赖注入
当您开始编写指令时,您可能会创建一些对许多指令都有用的工具方法。当然,您不希望重复这些代码,因此将这些工具组合起来并暴露给所有需要它们的指令是很有意义的。
你可以通过向包含指令的模块添加一个 factory
,然后在指令构造函数中指定工厂名称来实现这一点。例如
// the module
var app = angular.module("app", []);
// utilities shared by all directives
app.factory("myUtil", function () {
return {
// watch for changes in scope variables
// call update function when all have been initialized
watchScope: function (scope, props, updateFn, updateOnTimer) {
var cnt = props.length;
angular.forEach(props, function (prop) {
scope.$watch(prop, function (value) {
if (--cnt <= 0) {
if (updateOnTimer) {
if (scope.updateTimeout) clearTimeout(scope.updateTimeout);
scope.updateTimeout = setTimeout(updateFn, 50);
} else {
updateFn();
}
}
})
})
},
// change the value of a scope variable and notify listeners
apply: function (scope, prop, value) {
if (scope[prop] != value) {
scope[prop] = value;
if (!scope.$$phase) scope.$apply(prop);
}
}
)
});
上面列出的“myUtil”工厂包含两个工具函数
watchScope
为多个作用域变量添加监视器,并在其中任何一个发生变化时调用一个更新函数,但在指令初始化期间除外。它可以选择性地使用一个超时来避免过于频繁地调用更新函数。apply
改变作用域变量的值,并通知监听器这一变化(除非新值与旧值相同)。
要从自定义指令中使用这些工具函数,您可以这样写
app.directive("myDir", ["$rootScope", "myUtil",
function ($rootScope, myUtil) {
return {
restrict: "E",
scope: {
v1: "@", v2: "@", v3: "@", v4: "@", v5: "@", v6: "@"
},
template: "<div/>",
link: function (scope, element, attrs) {
var ctr = 0,
arr = ["v1", "v2", "v3", "v4", "v5", "v6"];
myUtil.watchScope(scope, arr, updateFn);
function updateFn() {
console.log("# updating my-dir " + ++ctr);
// modify DOM here
}
}
}
}]);
如您所见,我们只是将“myUtil”工厂添加到了指令构造函数中,使其所有方法都可供该指令使用。
你可以在这个 fiddle 中看到这段代码的实际效果:jsfiddle.net/Wijmo/GJm9M/
尽管看起来很简单,但其背后有很多有趣的事情在发生,才使得这一切得以实现。AngularJS 检查了指令,检测到“myUtil”参数,在模块定义中按名称找到了“myUtil”工厂,并将一个引用注入到正确的位置。依赖注入是一个很深的课题,在 AngularJS 文档中有描述。
依赖注入机制依赖于名称这一事实,带来了一个与代码压缩(minification)相关的问题。当你为了部署到生产环境而压缩代码时,变量名会改变,这可能会破坏依赖注入。为了解决这个问题,AngularJS 允许你使用一种数组语法来声明模块元素,该语法将参数名称作为字符串包含在内。如果你看上面的指令定义代码,会注意到声明中包含一个带有参数名称(本例中只有 "myUtil")的数组,后面跟着实际的构造函数。这使得 AngularJS 即使在压缩过程改变了构造函数参数的名称后,也能按名称寻找到 "myUtil" 工厂。
除了 factory
,AngularJS 还包括其他三个类似的概念:provider
、service
和 value
。它们之间的区别很微妙。我从开始使用 Angular 就一直在用 factory,到目前为止还没有需要用到其他几种。
示例
现在我们已经回顾了所有基础知识,是时候通过一些例子来展示这一切在实践中是如何运作的了。接下来的部分将描述一些有用的指令,它们阐明了要点,并应能帮助您开始编写自己的指令。
Bootstrap 折叠面板 (Accordion) 指令
我们的第一个例子是一对创建 Bootstrap 折叠面板的指令
Bootstrap 网站有一个示例,展示了如何使用纯 HTML 创建一个手风琴效果
<div class="accordion" id="accordion2">
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse"
data-parent="#accordion2" href="#collapseOne">
Collapsible Group Item #1
</a>
</div>
<div id="collapseOne" class="accordion-body collapse in">
<div class="accordion-inner">
Anim pariatur cliche...
</div>
</div>
</div>
<div class="accordion-group">
<div class="accordion-heading">
<a class="accordion-toggle" data-toggle="collapse"
data-parent="#accordion2" href="#collapseTwo">
Collapsible Group Item #2
</a>
</div>
<div id="collapseTwo" class="accordion-body collapse">
<div class="accordion-inner">
Anim pariatur cliche...
</div>
</div>
</div>
</div>
这能行,但标记太多了。而且标记中包含了基于 href 和元素 id 的引用,这使得维护变得不那么简单。
使用自定义指令,您可以用这段 HTML 达到同样的效果
<btst-accordion>
<btst-pane title="<b>First</b> Pane">
<div>Anim pariatur cliche …
</btst-pane>
<btst-pane title="<b>Second</b> Pane">
<div>Anim pariatur cliche …
</btst-pane>
<btst-pane title="<b>Third</b> Pane">
<div>Anim pariatur cliche …
</btst-pane>
</btst-accordion>
这个版本更小,更易于阅读和维护。
让我们看看这是如何实现的。首先,我们定义一个模块和“btstAccordion”指令
var btst = angular.module("btst", []);
btst.directive("btstAccordion", function () {
return {
restrict: "E", // the Accordion is an element
transclude: true, // it has HTML content
replace: true, // replace the original markup with our template
scope: {}, // no scope variables required
template: // template assigns class and transclusion element
"<div class='accordion' ng-transclude></div>",
link: function (scope, element, attrs) {
// make sure the accordion has an id
var id = element.attr("id");
if (!id) {
id = "btst-acc" + scope.$id;
element.attr("id", id);
}
// set data-parent and href attributes on accordion-toggle elements
var arr = element.find(".accordion-toggle");
for (var i = 0; i < arr.length; i++) {
$(arr[i]).attr("data-parent", "#" + id);
$(arr[i]).attr("href", "#" + id + "collapse" + i);
}
// set collapse attribute on accordion-body elements
// and expand the first pane to start
arr = element.find(".accordion-body");
$(arr[0]).addClass("in"); // expand first pane
for (var i = 0; i < arr.length; i++) {
$(arr[i]).attr("id", id + "collapse" + i);
}
},
controller: function () {}
};
});
该指令将 transclude
设置为 true,因为它有 HTML 内容。模板使用了 ng-transclude
指令来指明模板中的哪个元素将接收被嵌入的内容。在这种情况下,模板只有一个元素,所以没有其他选项,但并非总是如此。
代码中有趣的部分是 link
函数。它首先确保折叠面板元素有一个 id。如果没有,代码会根据指令作用域的 ID 创建一个唯一的 ID。一旦元素有了 ID,函数就使用 jQuery 选择具有“accordion-toggle”类的子元素,并设置它们的“data-parent”和“href”属性。最后,代码查找“accordion-body”元素并设置它们的“collapse”属性。
该指令还包含一个 controller
成员,其中包含一个空函数。这是必需的,因为手风琴将有子元素,这些子元素将检查父元素是否是正确的类型并指定了一个控制器。
下一步是定义折叠面板窗格指令。这个非常简单,大部分操作都直接在模板中完成,几乎没有代码
btst.directive('btstPane', function () {
return {
require: "^btstAccordion",
restrict: "E",
transclude: true,
replace: true,
scope: {
title: "@"
},
template:
"<div class='accordion-group'>" +
" <div class='accordion-heading'>" +
" <a class='accordion-toggle' data-toggle='collapse'>{{title}}</a>" +
" </div>" +
"<div class='accordion-body collapse'>" +
" <div class='accordion-inner' ng-transclude></div>" +
" </div>" +
"</div>",
link: function (scope, element, attrs) {
scope.$watch("title", function () {
// NOTE: this requires jQuery (jQLite won't do html)
var hdr = element.find(".accordion-toggle");
hdr.html(scope.title);
});
}
};
});
require
成员指定了 “btstPane
” 指令必须在 “btstAccordion
” 内部使用。transclude
成员表明窗格将包含 HTML 内容。scope
有一个单一的 “title
” 属性,它将被放置在窗格的头部。
在这种情况下,template
相当复杂。它是直接从 Bootstrap 示例页面复制过来的。请注意,我们使用了 ng-transclude
指令来标记将接收嵌入内容的元素。
我们本可以到此为止。模板中包含的 "{{title}}" 属性足以在正确的位置显示标题。然而,这种方法只允许在面板标题中使用纯文本。我们使用 link
函数将纯文本替换为 HTML,这样你就可以在折叠面板的标题中使用富内容了。
就这样。我们已经完成了第一对有用的指令。它们虽然小,但阐明了一些重要的要点和技巧:如何定义嵌套指令,如何生成唯一的元素 ID,如何使用 jQuery 操作 DOM,以及如何使用 $watch
函数监听作用域变量的变化。
谷歌地图指令
下一个例子是一个创建谷歌地图的指令
在我们开始编写指令之前,请记得在 HTML 页面中添加对 Google API 的引用
<!-- required to use Google maps -->
<script type="text/javascript"
src="https://maps.googleapis.com/maps/api/js?sensor=true">
</script>
接下来,我们来定义指令
var app = angular.module("app", []);
app.directive("appMap", function () {
return {
restrict: "E",
replace: true,
template: "<div></div>",
scope: {
center: "=", // Center point on the map
markers: "=", // Array of map markers
width: "@", // Map width in pixels.
height: "@", // Map height in pixels.
zoom: "@", // Zoom level (from 1 to 25).
mapTypeId: "@" // roadmap, satellite, hybrid, or terrain
},
center
属性被定义为引用传递(“=”),因此它将支持双向绑定。应用程序可以更改中心点并通知地图(当用户通过点击按钮选择一个位置时),地图也可以更改它并通知应用程序(当用户通过滚动地图选择一个位置时)。
markers
属性也定义为引用传递,因为它是一个数组,将其序列化为字符串可能会很耗时(但这样做也行)。
在这种情况下,link
函数包含相当多的代码。它必须:
- 初始化地图,
- 当作用域变量改变时更新地图,以及
- 监听地图事件并更新作用域。
这是如何做到的
link: function (scope, element, attrs) {
var toResize, toCenter;
var map;
var currentMarkers;
// listen to changes in scope variables and update the control
var arr = ["width", "height", "markers", "mapTypeId"];
for (var i = 0, cnt = arr.length; i < arr.length; i++) {
scope.$watch(arr[i], function () {
if (--cnt <= 0)
updateControl();
});
}
// update zoom and center without re-creating the map
scope.$watch("zoom", function () {
if (map && scope.zoom)
map.setZoom(scope.zoom * 1);
});
scope.$watch("center", function () {
if (map && scope.center)
map.setCenter(getLocation(scope.center));
});
监视作用域变量的函数与我们之前讨论共享代码时描述的函数类似。当变量有任何变化时,它会调用一个 updateControl
函数。updateControl
函数实际上是使用当前选定的选项来创建地图。
“zoom”和“center”作用域变量被区别对待,因为我们不希望每次用户选择新位置或放大缩小时都重新创建地图。这两个函数会检查地图是否已创建,并只更新它。
这是 updateControl
函数的实现
// update the control
function updateControl() {
// get map options
var options = {
center: new google.maps.LatLng(40, -73),
zoom: 6,
mapTypeId: "roadmap"
};
if (scope.center) options.center = getLocation(scope.center);
if (scope.zoom) options.zoom = scope.zoom * 1;
if (scope.mapTypeId) options.mapTypeId = scope.mapTypeId;
// create the map and update the markers
map = new google.maps.Map(element[0], options);
updateMarkers();
// listen to changes in the center property and update the scope
google.maps.event.addListener(map, 'center_changed', function () {
if (toCenter) clearTimeout(toCenter);
toCenter = setTimeout(function () {
if (scope.center) {
if (map.center.lat() != scope.center.lat ||
map.center.lng() != scope.center.lon) {
scope.center = { lat: map.center.lat(), lon: map.center.lng() };
if (!scope.$$phase) scope.$apply("center");
}
}
}, 500);
}
updateControl
函数首先准备一个反映作用域设置的 options
对象,然后使用这个 options
对象来创建和初始化地图。在创建包装 JavaScript 小部件的指令时,这是一种常见的模式。
创建地图后,该函数会更新标记并添加一个事件处理程序,以便在地图中心发生变化时得到通知。事件处理程序会检查当前地图中心是否与作用域的中心属性不同。如果不同,处理程序会更新作用域并调用 $apply
函数,这样 AngularJS 就会通知任何监听器该属性已发生变化。这就是 AngularJS 中双向绑定的工作原理。
updateMarkers
函数非常简单,不包含任何与 AngularJS 直接相关的内容,所以我们不在这里列出它。
除了地图指令,这个例子还包含
- 两个过滤器,用于将以常规数字表示的坐标转换为地理位置,例如 33°38'24"N, 85°49'2"W。
- 一个地理编码器,可将地址转换为地理位置(也基于谷歌地图API)。
- 一个使用 HTML5 地理定位服务获取用户当前位置的方法。
Google 的地图 API 极其丰富。这个指令仅仅触及了你能用它做什么的皮毛,但希望如果你有兴趣开发位置感知应用,这足以让你入门。
您可以在这里找到 Google 地图 API 的文档:https://developers.google.com/maps/documentation/
您可以在这里找到 Google 的许可条款:https://developers.google.com/maps/licensing
Wijmo 图表指令
下一个例子是一个显示实验数据和线性回归的图表。这个示例阐明了之前描述的场景,即您有一个特定的需求,它是专门化的,不太可能被商业产品附带的标准指令所覆盖
这个图表指令基于 Wijmo 折线图小部件,使用方法如下
<app-chart
data="data" x="x" y="y"
reg-parms="reg"
color="blue" >
</app-chart>
参数如下:
data
:一个包含要绘制属性的对象列表x, y
:将显示在 x 轴和 y 轴上的属性名称reg
:线性回归结果,一个包含回归参数和决定系数(即 R²)属性的对象。color
:图表上符号的颜色。
在指令的初始版本中,回归是在图表内部计算的,不需要“reg”参数。但我认为这不是正确的设计,因为回归参数在图表外部也很重要,因此应该在控制器的作用域内计算。
话不多说,这是指令的实现
app.directive("appChart", function (appUtil) {
return {
restrict: "E",
replace: true,
scope: {
data: "=", // array that contains the data for the chart.
x: "@", // property that contains the X values.
y: "@", // property that contains the Y values.
regParms: "=", // regression parameters (a and b coefficients)
color: "@" // color for the data series.
},
template:
"<div></div>",
link: function (scope, element, attrs) {
// watch for changes in the scope variables
appUtil.watchScope(scope, ["x", "y", "color"], updateChartControl, true, true);
// update chart data when data changes
scope.$watch("data", updateChartData);
这第一块代码像往常一样定义了指令类型和作用域。link
函数使用了我们之前介绍的 watchScope
方法来监视多个作用域变量,并在任何作用域变量发生变化时调用一个 updateChartControl
方法。
请注意,我们对数据使用了一个单独的 scope.$watch
调用,因为我们预计图表数据的变化会比其他属性更频繁,所以我们将提供一个更高效的处理器 updateChartData
来处理这些变化。
这是 updateChartControl
方法的实现,它实际上创建了图表。
// create/update the chart control
function updateChartControl(prop, val) {
// use element font in the chart
var fontFamily = element.css("fontFamily");
var fontSize = element.css("fontSize");
var textStyle = { "font-family": fontFamily, "font-size": fontSize };
// set default values
var color = scope.color ? scope.color : "red";
// build options
var options = {
seriesStyles: [
{ stroke: color, "stroke-width": 0 },
{ stroke: "black", "stroke-width": 1, "stroke-opacity": .5 }
],
seriesHoverStyles: [
{ stroke: color, "stroke-width": 0 },
{ stroke: "black", "stroke-width": 2, "stroke-opacity": 1 }
],
legend: { visible: false },
showChartLabels: false,
animation: { enabled: false },
seriesTransition: { enabled: false },
axis: {
x: { labels: { style: textStyle }, annoFormatString: "n0" },
y: { labels: { style: textStyle }, annoFormatString: "n0" }
},
textStyle: textStyle
};
// create the chart
element.wijlinechart(options);
// go update the chart data
updateChartData();
}
这段代码与我们之前在谷歌地图指令中使用的代码类似。它构建了一个包含配置信息的 options
对象,其中一些信息基于指令参数,然后使用这个 options
对象通过调用 element.wijlinechart
方法来创建实际的图表。
创建图表小部件后,代码调用 updateChartData
方法来填充图表。updateChartData
方法创建了两个数据系列。第一个代表通过作用域变量传入的数据,第二个代表回归。第一个系列的数据点数量与控制器传入的数量相同,并以符号形式显示。第二个系列代表线性回归,因此只有两个点。它以实线形式显示。
Wijmo 表格指令
我们的最后一个例子是一个实现可编辑数据网格的指令
这个指令基于 Wijmo 表格小部件,使用方式如下
<wij-grid
data="data"
allow-editing="true"
after-cell-edit="cellEdited(e, args)" >
<wij-grid-column
binding="country" width="100" group="true">
</wij-grid-column>
<wij-grid-column
binding="product" width="140" >
</wij-grid-column>
<wij-grid-column
binding="amount" width="100" format="c2" aggregate="sum" >
</wij-grid-column>
</wij-grid>
“wij-grid”指令指定了表格的属性,而“wij-grid-column”指令指定了单个表格列的属性。上面的标记定义了一个可编辑的表格,包含“country”、“product”和“amount”三列。值按国家分组,分组行显示每个组的总金额。
这个指令最有趣的部分是父指令“wij-grid”和其子指令“wij-grid-column”之间的连接。为了实现这种连接,父指令指定了一个 controller
函数,如下所示
app.directive("wijGrid", [ "$rootScope", "wijUtil", function ($rootScope, wijUtil) {
return {
restrict: "E",
replace: true,
transclude: true,
template: "<table ng-transclude/>",
scope: {
data: "=", // List of items to bind to.
allowEditing: "@", // Whether user can edit the grid.
afterCellEdit: "&", // Event that fires after cell edits.
allowWrapping: "@", // Whether text should wrap within cells.
frozenColumns: "@" // Number of non-scrollable columns
},
controller: ["$scope", function ($scope) {
$scope.columns = [];
this.addColumn = function (column) {
$scope.columns.push(column);
}
}],
link: function (scope, element, attrs) {
// omitted for brevity, see full source here:
// http://jsfiddle.net/Wijmo/jmp47/
}
}
}]);
controller
函数使用了前面提到的数组语法进行声明,以便可以被压缩。在这个例子中,控制器定义了一个 addColumn
函数,该函数将被子指令“wij-grid-column”调用。然后,父指令将能够访问标记中指定的列信息。
这是“wij-grid-column”指令如何使用这个函数的
app.directive("wijGridColumn", function () {
return {
require: "^wijGrid",
restrict: "E",
replace: true,
template: "<div></div>",
scope: {
binding: "@", // Property shown in this column.
header: "@", // Column header content.
format: "@", // Format used to display numeric values in this column.
width: "@", // Column width in pixels.
aggregate: "@", // Aggregate to display in group header rows.
group: "@", // Whether items should be grouped by the values in this column.
groupHeader: "@" // Text to display in the group header rows.
},
link: function (scope, element, attrs, wijGrid) {
wijGrid.addColumn(scope);
}
}
});
require
成员指定 "wij-grid-column" 指令需要一个 "wij-grid" 类型的父指令。link 函数接收一个对父指令(控制器)的引用,并使用 addColumn
方法将自己的作用域传递给父级。该作用域包含了表格创建列所需的所有信息。
更多指令
除了本文讨论的示例外,附加的示例中还包含了近50个其他指令,您可以直接使用和修改。示例应用程序本身是按照这里建议的原则构建的,所以您在浏览它时应该不会遇到问题。
在示例中,指令可以在 scripts/directives 文件夹下的三个文件中找到
- btstDctv: 包含 13 个基于 Bootstrap 库的指令。这些指令包括选项卡、手风琴、弹出框、工具提示、菜单、预输入和数字输入。
- googleDctv: 包含两个基于 Google JavaScript API 的指令:一个地图和一个图表。
- wijDctv: 包含 24 个基于 Wijmo 库的指令。这些指令包括输入、布局、网格和图表。
所有三个指令模块都以源代码和压缩格式提供。我们使用了 Google 的 Closure 压缩器,您可以在这里在线使用它:http://closure-compiler.appspot.com/home。
这里有 Angular Explorer 示例的在线版本:http://demo.componentone.com/wijmo/Angular/AngularExplorer/AngularExplorer。
结论
我希望您阅读本文时感到愉快,并且和我一样对 AngularJS 和自定义指令感到兴奋。
请随时使用示例中的代码,并与我联系,提供任何反馈。我特别感兴趣的是关于新指令的想法,以及如何让所呈现的指令变得更强大、更有用。
参考文献
- Google 的 AngularJS。AngularJS 主页。
- AngularJS 指令文档。关于 AngularJS 指令的官方文档。
- AngularJS 指令与 JavaScript 计算机科学。一篇关于编写 AngularJS 指令的有趣文章。
- 视频教程:AngularJS 基础知识 60 分钟速成。由 Dan Wahling 制作的介绍 AngularJS 的精彩视频。
- 关于那些指令。由 AngularJS 团队成员制作的一系列关于指令及更多内容的视频。
- Egghead.io。由 John Lindquist 制作的一系列关于 AngularJS 的操作视频。
- Polymer 项目。AngularJS 之后的未来。
- Wijmo AngularJS 示例。几个使用 AngularJS 和自定义指令创建的在线演示。