w3ctech

[译] AngularJS内幕详解之 Directive

这系列的上一篇文章,我讨论了scope事件以及digest循环的行为。这一次,我将谈论指令。这篇文章包括 独立的scope,内嵌,link函数,编译器,指令控制器等等

如果这个图表看起来非常的费解,那么这篇文章很适合你。

Angular

(Image credit: Angular JS documentation) (Large version)

声明: 这篇文字是基于 AngularJS v1.3.0 tree.

AngularJS中,指令是 通常是小的 组件, 这意味着跟DOM交互。他经常被用作顶层DOM的抽象层,大多数的操作可以不用jQuery,jqLite等包装的DOM元素。通过使用表达式、其他的指令来得到你想要的结果是高明的。

在AngularJS的核心里,指令可以绑定元素的属性(例如可见性,class列表,内部文本,内部HTML或者值)到scope的属性或表达式。最值得注意的是,一旦监测到scope中的变化被标记,这些绑定就会被更新。反过来也是相似的,使用$observe函数能够监测DOM属性,当监测到属性变化时会触发一个回调。

简单的说,指令是AngularJS中很重要的一面。如果你精通指令,那么处理AngularJS程序你将不会有任何问题。同理,如果你不设法理解指令,你将很难将其用在合适的地方。熟练指令需要时间,尤其是你在尝试不仅仅是用jQuery封装代码就完事。

在AngularJS,你能够建立组件化的指令、服务和控制器,它们可以复用,只要复用是合理的。例如你有一个简单的指令,基于一个监测的scope表达式来切换class ,在你的代码中用来标识你的特定组件的状态,我想那是一个十分通用的指令,能够在你的程序里到处使用到。你可以有一个服务集成键盘快捷键的服务、控制器、指令和其他注册快捷键的服务,它支持所有键盘快捷键的处理在一个自包含的服务中。

指令也是可重用的功能,但经常被分配给 DOM 片段,或者模板,而不是仅仅提供功能。是时候深入了解 AngularJS 指令及其他的用法了。

之前,我列出了 AngularJS 中 scope 上可用的属性,我用他来解释 digest 机制以及 scope 如何操作的。 我会用同样的方式来解释指令, 但是这次我将剖析指令的工厂函数返回的对象的属性,以及每个这些属性如何影响我们所定义的指令。

首先要注意下指令的名字, 来看一个简单的例子。

angular.module('PonyDeli').directive('pieceOfFood', function () {
  var definition = { //

尽管在上面的的代码片段中我们定义了一个命名为'pieceOfFood'的指令,AngularJS约定在 HTML 标记里使用破折号的形式连接名字。如果这个指令作为一个属性实现,那么我在 HTML 中就会像这样调用:

<span piece-of-food></span>

默认情况,指令只能作为属性被触发。但是如果你想改变这种方式,你可以使用 restrict 属性。

如何定义一个指令作为标签使用。

angular.module('PonyDeli').directive('pieceOfFood', function () {
  return {
    restrict: 'E',
    template: // ...
  };
});

出于某种原因,我无法捉摸它们决定混淆什么,本来一个很有表达能力的框架,却以单个大写字母结尾来定义指令是如何被限制的。GitHub 上有一个可用的restrict选项列表, restrict 的默认值是 EA

  • 'A': attributes are allowed
  • 'A': 允许作为一个属性
    <span piece-of-food></span>
    
  • 'E': elements are allowed
  • 'E': 允许作为一个元素
    <piece-of-food></piece-of-food>
    
  • 'C': as a class name
  • 'C': 作为一个类名
    <span class='piece-of-food'></span>
    
  • 'M': as a comment
  • 'M': 作为一个注释
    <!-- directive: piece-of-food -->
    
  • 'AE': You can combine any of these to loosen up the restriction a bit.
  • 'AE': 可以结合上面的任意值来放松限制。

千万别用 'C' 或者 'M' 来限制你的指令。 用 'C' 不能使之在标记中凸显出来, 用 'M' 是为了向后兼容。 如果你觉得有趣, 你可以用一个例子来设置 restrict 为 'ACME'。

不幸的是, 指令定义对象的其他属性是很难理解的。

如何设置一个指令与父级 scope 交互。

因为我们在之前的文章中大范围的谈论了 scope,知道如何正确地使用 scope 属性,所以不应为此感到痛苦。我们从默认值开始, scope: false,使作用域链保持不受影响:依照我在上篇文章提到的规则你将得到与元素相关联的所有作用域。

当你的指令不会和 scope有互动,保持作用域链不变显然是有用的,但这种情况很少发生。一种更常见有用的情景是,不改变作用域创建一个指令,给他一个作用域,实例化多次并且只跟一个scope属性交互———指令的名字。跟默认值restrict: 'A'结合是最有表达力的。(下面的代码在 Codepen上可用)

angular.module('PonyDeli').directive('pieceOfFood', function () {
  return {
    template: '{{pieceOfFood}}',
    link: function (scope, element, attrs) {
      attrs.$observe('pieceOfFood', function (value) {
        scope.pieceOfFood = value;
      });
    }
  };
});
<body ng-app='PonyDeli'> 
  <span piece-of-food='Fish & Chips'></span>
</body>

这里有几个值得注意的点我们还没讨论到。你将在后面的章节了解到 link 属性。 暂且想一下作为一个控制器如何操作每个实例化的指令

在指令的链接函数里,我们可以获得元素上的属性集合。这个集合有一个特殊的方法,叫$observe(), 当一个属性变化时可以触发一个回调。没有监听属性变化时,属性永远不会对应到scope上,也无法绑定到我们的模板上。

我们可以改下上面的代码,通过引进scope.$eval,让他更可用。记得他是如何依靠scope被用来解析一个表达式的吗?看下面的代码帮助我们更好的理解(也可以查看 Codepen )。

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

deli.controller('foodCtrl', function ($scope) {
  $scope.piece = 'Fish & Chips';
});

deli.directive('pieceOfFood', function () {
  return {
    template: '{{pieceOfFood}}',
    link: function (scope, element, attrs) {
      attrs.$observe('pieceOfFood', function (value) {
        scope.pieceOfFood = scope.$eval(value);
      });
    }
  };
});
<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <span piece-of-food='piece'></span>
</body>

这个例子中,通过 scope 我解析出了属性的值 piece,这个值定义在controller 中的 $scope.piece。当然,直接使用模板方式如{{piece},但是那样需要你特别注意你想追踪的scope属性。这种方式增加了一点灵活性,但当你想在在所有的指令间共享scope时, 如果你尝试用同样的scope添加多个指令则会导致意外的结果

你可以创建一个子作用域来解决这个问题, 他继承自父级的原型。为了创建子作用域, 你仅仅需要声明 scope: true。

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

deli.controller('foodCtrl', function ($scope) {
  $scope.pieces = ['Fish & Chips', 'Potato Salad'];
});

deli.directive('pieceOfFood', function () {
  return {
    template: '{{pieceOfFood}}',
    scope: true,
    link: function (scope, element, attrs) {
      attrs.$observe('pieceOfFood', function (value) {
        scope.pieceOfFood = scope.$eval(value);
      });
    }
  };
});
<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <p piece-of-food='pieces[0]'></p>
  <p piece-of-food='pieces[1]'></p>
</body>

正如你所见,现在我们可以使用指令的多个实例来达到预期的效果,因为每个指令都创建了自己的作用域。 但是,这里有一个局限:一个元素的多个指令都是一个相同的作用域。

注意:如果同一元素的多个指令需要新的作用域,那么只会创建一个作用域。

最后一个选项是用来创建一个本地的,独立的作用域。独立的作用域跟子作用域不同在于前者不是继承自他的父级(但是也可以通过 scope.$parent 访问)。你可以像这样声明一个独立的作用域:scope: {}。你可以添加一些属性到这个对象,用来从父级scope获取数据绑定并且当前作用域也可访问。很像restrict,独立scope的属性简洁但语法复杂,你可以用符号例如:&,@ 和=来定义属性的绑定方式。

你可以省略属性名如果你打算使用你本地scope的属性名。那就是说,pieceOfFood: '=' 是 pieceOfFood: '=pieceOfFood'的简写;他们是相等的。

那么,这些符号是什么意思?下面枚举的例子,可以帮助你破解他们

使用 @ 绑定父级作用域]监测属性的结果。

<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <p note='You just bought some {{type}}'></p>
</body>
deli.directive('note', function () {
  return {
    template: '{{note}}',
      scope: {
        note: '@'
      }
  };
});

等效于观察属性变化来更新本地scope。当然,用 @ 符号是更多的“AngularJS”。

deli.directive('note', function () {
  return {
    template: '{{note}}',
    scope: {},
    link: function (scope, element, attrs) {
      attrs.$observe('note', function (value) {
        scope.note = value;
      });
    }
  };
});

指令的选项很复杂时,属性监测器很有用。如果我们想通过改变选项来改变指令的行为,我们自己写代码使用attrs.$observe创建检测,比AngularJS 内部去做更有意义,更快。

这个例子中,仅仅替换了 scope.note = value , 如上面的$observe操作所示,任何你想要添加到$watch监听上都应该这样写。

注意:请记住,当遇到 @时,我们谈论的是观察和属性,而不是绑定到父作用域。

使用 & 提供一个 表达式解析函数 ,他的上下文是父级作用域。

<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <p note='"You just bought some " + type'></p>
</body>
deli.directive('note', function () {
  return {
    template: '{{note()}}',
    scope: {
      note: '&'
    }
  };
});

下面,我已经在link函数里扼要地实现一个相同的功能 ,这个例子中你看不到 & 。这个比用 @ 要长一点点,因为他 是在属性里解析表达式的,也构建了一个可重用的功能。

deli.directive('note', function ($parse) {
  return {
    template: '{{note()}}',
    scope: {},
    link: function (scope, element, attrs) {
      var parentGet = $parse(attrs.note);

      scope.note = function (locals) {
        return parentGet(scope.$parent, locals);
      };
    }
  };
});

真如我们所见,表达式构造器会生成了一个依赖父级scope的方法。你可以随时执行他,甚至可以监测到输出的变化。这个方法在父级scope应该作为只读的查询对待。这样在两种情况下非常有用,当你需要监听父级scope的变化时,这种情况下你应该在表达式函数 note()上设置一个监听, 本质上就像上面的例子。

另一种情况是, 当你需要访问父级scope方法时会派上用场。假设父级scope有一个方法用来更新一个 table,而你的本地 scope用来显示一个table的行。 如果按钮在子scope里,那么通过使用 & 绑定和使用父级scope的刷新方法是很有用的。这仅仅是个人的例子 —— 你或许更喜欢用事件来处理这类事情, 甚至用某种方式构造你的程序来避免一些复杂的事。

使用 = 设置 本地scope与父级scope间的双向数据绑定。

<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <button countable='clicks'></button>
  <span>Got {{clicks}} clicks!</span>
</body>
deli.directive('countable', function () {
  return {
    template:
      '' +
        'Click me {{remaining}} more times! ({{count}})' +
      '',
    replace: true,
    scope: {
      count: '=countable'
    },
    link: function (scope, element, attrs) {
      scope.remaining = 10;

      element.bind('click', function () {
        scope.remaining--;
        scope.count++;
        scope.$apply();
      });
    }
  };
});

双向数据绑定比 & 或者 @ 更复杂一点

deli.directive('countable', function ($parse) {
  return {
    template:
      '' +
        'Click me {{remaining}} more times! ({{count}})' +
      '',
    replace: true,
    scope: {},
    link: function (scope, element, attrs) {

      // you're definitely better off just using '&'

      var compare;
      var parentGet = $parse(attrs.countable);
      if (parentGet.literal) {
        compare = angular.equals;
      } else {
        compare = function(a,b) { return a === b; };
      }
      var parentSet = parentGet.assign; // or throw
      var lastValue = scope.count = parentGet(scope.$parent);

      scope.$watch(function () {
        var value = parentGet(scope.$parent);
        if (!compare(value, scope.count)) {
          if (!compare(value, lastValue)) {
            scope.count = value;
          } else {
            parentSet(scope.$parent, value = scope.count);
          }
        }
        return lastValue = value;
      }, null, parentGet.literal);

      // I told you!

      scope.remaining = 10;

      element.bind('click', function () {
        scope.remaining--;
        scope.count++;
        scope.$apply();
      });
    }
  };
});

这种形式的数据绑定可以说是最有用的。在这个例子中,父级scope属性与本地scope保持同步。任何时候的本地scope的值发生改变,都会设置到父级scope上。同样,任何时候的父级scope值改变了,本地scope也能更新。最直接有用的情形是,你有一个子scope用来展示父级scope的子模块。试想一个经典CRUD的table(增、删、改、查)。table作为一整个父级scope, 每个row 包含一个独立的指令用来双向绑定每行的数据模型。这样的模块化,仍然可以保持table和其孩子间的有效通信。

刚刚花了很多的文字,但是我认为我已经总结好了指令声明与scope属性如何工作以及最常见的使用方法。

当指令包含小的可重用的 HTML 代码片段时,指令变得非常高效, 这是指令的真正力量来源。 当手动启动指令时,这些模板可以作为纯文本 或AngularJS 查询的资源提供给指令。

  • template24 以纯文本提供视图模板
    template: '<span ng-bind="message" />'
    
  • templateUrl25 这样允许你提供一个链接作为 HTML模板
    templateUrl: /partials/message.html
    

用 templateUrl 将 HTML从你的 link函数中分开是很棒的。当指令第一次初始化时会发出一个AJAX请求。但是, 如果你构建任务提前填充到 $templateCache,就可以规避AJAX请求 例如grunt-angular-templates

你也可以用一个函数 function (tElement, tAttrs) 作为模板,但是这既不需要也不实用

  • replace 模板应该作为子元素还是内联插入?

关于这个特性的文档是可悲而混乱的:

replace

specify where the template should be inserted. Defaults to false.

  • true — the template will replace the current element
  • false — the template will replace the contents of the current element

replace

声明模板应该插入到什么地方。默认值是 false。

  • true —— 模板将替换当前的元素
  • false —— 模板将替换当前元素的内容

那么,当 replace: false, 指令实际上替换了元素? 这听起来是不对的。 如果你查看我的文章,你会发现如果 replace: false, 元素只是只是被追加, 如果 replace: true, 将会有序的替换元素。

根据经验,尽量保持替换到最低限度。当然,只要有可能,指令应该与DOM保持尽可能少的干扰。

指令被编译的结果是 pre-link 函数和 post-link函数。你可以自定义代码来返回这些函数或只是提供给他们。下面有两种方式来提供 link 函数。我提醒你:这又是另一个那些AngularJS“特色”, 我觉得更多的是一种缺点。 因为它只会使新人更加困惑,没有任何好处。

compile: function (templateElement, templateAttrs) {
  return {
    pre: function (scope, instanceElement, instanceAttrs, controller) {
      // pre-linking function
    },
    post: function (scope, instanceElement, instanceAttrs, controller) {
      // post-linking function
    }
  }
}
compile: function (templateElement, templateAttrs) {
  return function (scope, instanceElement, instanceAttrs, controller) {
    // post-linking function
  };
}
link: {
  pre: function (scope, instanceElement, instanceAttrs, controller) {
    // pre-linking function
  },
  post: function (scope, instanceElement, instanceAttrs, controller) {
    // post-linking function
  }
}
link: function (scope, instanceElement, instanceAttrs, controller) {
  // post-linking function
}

事实上,你甚至可以忘掉关于指令定义的对象,我们到此讨论的只是返回后的 post-link 函数。但是,不建议 AngularJS 初学者掌握它, 最好远离它。注意,link函数中声明控制器或指令时不支持依赖注入。大多数情况下,AngularJS 的依赖注入在顶层API可用,但是大多数其他方法有封装好的静态参数列表,你不能改变。

deli.directive('food', function () {
  return function (scope, element, attrs) {
    // post-linking function
  };
});

继续进行之前,有个 AngularJS 文档很重要的注意事项, 我希望你看看:

注意:如果模板被克隆了,模板实例和link实例可能是不同的对象。出于这点,在compile函数里,除了 转换那些可以被安全操作的克隆DOM节点外,都是不安全的。特别是,DOM监听器注册应该在link函数中,而不是compile函数中。

目前compile函数增加了第三个参数,一个嵌入(transclude)link函数,但是他被弃用了。同样的,你不应该在comiple函数执行期间操作DOM(templateElement)。为了编译完全,请帮忙直接提供 pre-link 函数和 post-link 函数。通常,一个post-link 函数就够了,即是你分配给定义对象的link函数。

我这里给你一条规则,始终使用post-link 函数。 如果一个 scope 一定要在 DOM链接之前填充好, 可以在pre-link 函数中操作,但是要在post-link 函数中绑定该功能,就像你正常的操作。你将很少需要这样做, 但是我认为值得你注意。

link: {
  pre: function (scope, element, attrs, controller) {
    scope.requiredThing = [1, 2, 3];
  },
  post: function (scope, element, attrs, controller) {
    scope.squeal = function () {
      scope.$emit("squeal");
    };
  }
}
  • controller 这是一个指令的控制器实例。

指令可以有控制器,这说得通是因为指令可以创建 scope 。该控制器在所有的同一 scope 的指令中共享,同时可以作为 link 函数的第四个参数被访问到。在同一层级的scope上,这些控制器是指令间的一个可用的通信信道,也可能包含指令自身。

  • controllerAs 这是在模板中使用的 controller 别名

使用控制器别名允许你在模板里面引用控制器,因为他在 scope 中是可见的。

  • require 如果你没有链接其他的指令到某个元素上会抛出一个错误。

这里有个非常简单的 require 文档, 我就简单的复制在这:

Require another directive and inject its controller as the fourth argument to the linking function. The require takes a string name (or array of strings) of the directive(s) to pass in. If an array is used, the injected argument will be an array in corresponding order. If no such directive can be found, or if the directive does not have a controller, then an error is raised. The name can be prefixed with:

  • (no prefix) Locate the required controller on the current element. Throw an error if not found
  • ? Attempt to locate the required controller or pass null to the link fn if not found
  • ^ Locate the required controller by searching the element’s parents. Throw an error if not found
  • ?^ Attempt to locate the required controller by searching the element’s parents or pass null to the link fn if not found

引入其他指令并注入到控制器中,并作为当前指令的链接函数的第四个参数。require使用字符串或数组元素来传入指令。如果是数组,注入的参数是一个相应顺序的数组。如果这样的指令没有被找到,或者该指令没有控制器, 就会报错。 require参数可以加一些前缀:

  • (没有前缀)如果没有前缀,指令将会在自身所提供的控制器中进行查找,如果没有找到任何控制器就抛出一个错误。
  • ? 如果在当前指令中没有找到所需要的控制器,会将null作为传给link函数的第四个参数。
  • ^ 如果添加了^前缀,指令会在上游的指令链中查找require参数所指定的控制器。
  • ?^ 将前面两个选项的行为组合起来,我们可选择地加载需要的指令并在父指令链中进行查找。

在我们的工作中,如果我们的指令需要依赖其他的指令时,require 很有用。 举个例子, 你或许有个 dropdown 指令,他依赖一个 list-view 指令, 或者一个错误弹框的指令依赖一个错误消息指令。在下面的例子中,反过来说,定义一个 needs-model 指令, 如果他没有找到依赖的 ng-model 就会抛出一个错误 —— 因为 needs-model指令使用了那个指令,或者某种程度上取决于他在元素上可用。

angular.module('PonyDeli').directive(‘needsModel’, function () {
  return {
    require: 'ngModel’,
  }
});
<div needs-model ng-model=’foo’></div>
  • priority priority 定义了指令执行的顺序。

When there are multiple directives defined on a single DOM element, sometimes it is necessary to specify the order in which the directives are applied. The priority is used to sort the directives before their compile functions get called. Priority is defined as a number. Directives with greater numerical priority are compiled first. Pre-link functions are also run in priority order, but post-link functions are run in reverse order. The order of directives with the same priority is undefined. The default priority is 0.


当一个DOM元素上定义了多个指令时,有时指定指令的执行顺序很有必要。指令在未调用compile函数前根据priority排序。 priority 被定义为一个数字。拥有大数字的priority的指令先执行。 pre-link函数也按照priority顺序执行,但是post-link 函数按照相反的顺序执行。 相同优先级的指令顺序是 undefined 。priority 的默认值是 0.

  • terminal terminal 防止指令进一步执行。

If set to true then the current priority will be the last set of directives which will execute (any directives at the current priority will still execute as the order of execution on same priority is undefined).


如果terminal设置为 true, 那么当前优先级的指令就是当前元素的指令集的最后一个可执行的(任何比当前优先级高的指令都会按照顺序执行, 相同优先级的顺序是undefined)。

  • transclude 对元素内容的编译和指令中使其可用。

这个属性允许两个值。你可以设置为 true 允许嵌套;也可以设置为 'element',这种情况下整个元素,包括一些低优先定义的级指令, 都被嵌入。

更高的水平是,嵌套允许指令的使用自定义的 HTML 片段,用 ng-transclude 指令嵌入到指令的一部分。这听起来复杂,举个例子:

angular.module('PonyDeli').directive('transclusion', function () {
  return {
    restrict: 'E',
    template:
      '<div ng-hide="hidden" class="transcluded">' +
        '<span ng-transclude></span>' +
        '<span ng-click="hidden=true" class="close">Close</span>' +
      '</div>',
    transclude: true
  };
});
<body ng-app='PonyDeli'>
  <transclusion>
    <span>The plot thickens!</span>
  </transclusion>
</body>

你可以在CodePen查看。 当你尝试在混合后获取scope时会发生什么? 指令里获取嵌入的内容总是从父级内容响应,即使在指令中被替换,即使指令的呈现出一个独立的scope。这正是你期待的,因为嵌入内容定义在销毁的内容,他属于父级scope,而不是指令的scope。指令依然绑定的是本地scope。

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

deli.controller('foodCtrl', function ($scope) {
  $scope.message = 'The plot thickens!';
});

deli.directive('transclusion', function () {
  return {
    restrict: 'E',
    template:
      '<div ng-hide="hidden" class="transcluded">' +
        '<span ng-transclude></span>' +
        '<span ng-click="hidden=true" class="close" ng-bind="close"></span>' +
      '</div>',
    transclude: true,
    scope: {},
    link: function (scope) {
      scope.close = 'Close';
    }
  };
});
<body ng-app='PonyDeli' ng-controller='foodCtrl'>
  <transclusion>
    <span ng-bind='message'></span>
  </transclusion>
</body>

你也可以在CodePen查看

扩展阅读

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复