编写 Angular Directive 时,到底该用 link 还是 controller,或者 compile。 看官方文档的示例代码时,也是一会儿用 link, 一会儿又用 controller。网上搜问题时遇到的代码也都是用每个的都有。

那么问题来了,这三者有何区别,什么情况下用哪个最合适。

重新认识

从官方文档 Comprehensive Directive API,可以找到一段 directive 完整定义的示例代码,其中,directiveDefinitionObject 就是完整定义一个 directive 的配置对象。是它告诉 Angular 的 $compile 如何去解析生成这个 directive。

var myModule = angular.module(...);

myModule.directive('directiveName', function factory(injectables) {
  var directiveDefinitionObject = {
    priority: 0,
    template: '<div></div>', // or // function(tElement, tAttrs) { ... },
    // or
    // templateUrl: 'directive.html', // or // function(tElement, tAttrs) { ... },
    transclude: false,
    restrict: 'A',
    templateNamespace: 'html',
    scope: false,
    controller: function($scope, $element, $attrs, $transclude, otherInjectables) { ... },
    controllerAs: 'stringIdentifier',
    bindToController: false,
    require: 'siblingDirectiveName', // or // ['^parentDirectiveName', '?optionalDirectiveName', '?^optionalParent'],
    multiElement: false,
    compile: function compile(tElement, tAttrs, transclude) {
      return {
         pre: function preLink(scope, iElement, iAttrs, controller) { ... },
         post: function postLink(scope, iElement, iAttrs, controller) { ... }
      }
      // or
      // return function postLink( ... ) { ... }
    },
    // or
    // link: {
    //  pre: function preLink(scope, iElement, iAttrs, controller) { ... },
    //  post: function postLink(scope, iElement, iAttrs, controller) { ... }
    // }
    // or
    // link: function postLink( ... ) { ... }
  };
  return directiveDefinitionObject;
});

从上面示例代码可以看到本文要讨论的三个函数的关系。三者可以并列,但 compile 和 link 的关系有点微妙。这里,link 严格意义上是分为 preLink 及 postLink 的。默认情况下 link 指的是 postLink。两者之所以微妙,是因为有 compile 定义的情况下,配置对象中的 link 属性会被忽略。此时如果想使用 link 方法的话,需要在 compile 里进行返回。

平时只注重使用,也没细看。现在根据三者执行的顺序,来仔细认识一下他们,以看看三者的区别。

从 Stackoverflow 扒来的神图展示了 Angular 启动时都发生了些什么

从 Stackoverflow 扒来的神图展示了 Angular 启动时都发生了些什么

从图中可看出,Anuglar 启动后,主要两部分,

$compile 阶段

这里面包含了 Directive 的 compile 方法。

DOM 模板被加载后则开始了编译。遍历所有节点,找出 directive 然后调用每个 directive 的 cmopile 方法。这里使用的 DOM 模板代码为原始的 DOM 模板。

Angular directive compile 阶段

Angular directive compile 阶段

nodeLinkFn 阶段

这里面包含了 Directive 的 controller 以及 link (preLinkpostLink)。

将原始的 DOM 模板代码生成到页面后,就认为实例化了该模板,因为该模板和一个实例 directive 进行了绑定,有确定的作用域 scope。所以这一阶段使用的 DOM 区别于前面,是实例化的 DOM。

实例化的 DOM 除了从原始 DOM 直接编译而来,还有的是通过 ngRepeat 等在代码中动态生成的。无论哪种形式而来,一旦 DOM 被渲染到页面,则开始的 link 阶段的工作。

Angular directive link 阶段

Angular directive link 阶段

详细看看各步骤

为了更好理解各执行步骤,考察下面的示例代码:

<div ng-repeat="i in [0,1,2]">
    <my-element>
        <div>Inner content</div>
    </my-element>
</div>

其中用到的 directive 代码为:

myApp.directive( 'myElement', function() {
    return {
        restrict:   'EA',
        transclude: true,
        template:   '<div><div ng-transclude></div></div>'
    }
});

compile

compile 主要用来对模板代码进行转换,大部分的 directive 用不到。 link 阶段的 DOM 是这一阶段产出的克隆版本。

首先来到的是 $compile 阶段。这里面包含了 directive 的 compile 方法。从上面示例代码中可以看出,它的签名是

compile: function compile( tElement, tAttributes ) { ... }

这里参数命名值得提一下,tElement 实际上指 template element,指的是直接从模板拿到的原始代码,以区别于实例的 element iElement (instance element),后者指 directive 实例化之后的 DOM 代码,用在 link 方法中。 tAttributes 同理。

在 compile 执行之前,通过 ng-transclude 嵌入的代码会先被移除,然后模板代码被放入 directive 标签内。所以传给 compile 的 DOM 成了这样:

<my-element>
    <div>
        ""
        <div ng-transclude></div>
    </div>
</my-element>

然后就是调用 compile 方法。完了之后会将这个 directive 下所有子组件再走一遍这个流程。

组件的实例化

compile 可以理解为是对原始模板的编译处理。针对一个 directive 只进行一次。之后便是将编译好的代码实例出一个组件来。

上面参数命名的地方也提到过实例。指的也就是页面中一个 direcitve 的生成。这里,我们测试用的代码中,ng-repeat="i in [0,1,2]" 会生成三个 myElement 的实例。也就是说,下面的步骤会进行三次,每实例化一个 directive 都会走一遍下面的流程。

Controller

从上面示例代码中,可以看到 controller 方法的签名是这样的:

controller: function( $scope, $element, $attrs, $transclude ) { ... }

controller 的执行表示 directive 进入了上图中标识的 nodeLinkFn 阶段。这里的 nodeLinkFn 方法来自上一阶段 $compile 的返回,并且在返回时提供了 scope 入参。

这里,根据配置属性上 scope 的不同设置,传递给 controller 的 $scope 会有不同,具体见官方文档 scope 部分

  • scope: false 默认值。不会有新的 scope 被创建,将使用父级 scope。
  • scope: true 会创建一个继承于父scope 的新 scope 给 directive 使用。
  • scope: {...} 创建一个独立的 scope 给 directive 使用。

然后 controller 则以上面的 scope 和实例化之后的 DOM 元素开始运行。

签名:

function preLink(scope, iElement, iAttrs, controller) { ... },

发生在所有子组件被 link 前。事实上这一阶段从视觉上看不出发生了什么。之后便是遍历所有子组件,将正确的 scope 传播下去。

可以理解为主要是遍历和组装各子组件使之 ready。

我们通常的说的 link 指的是 postLink,大部分逻辑是在这里面的。并且,无论是从 compile 方法返回的 link,还是配置对象中的 link, 默认指的都是 postLink。

它的签名是同 preLink 一样,入参上没有什么区别。

function postLink(scope, iElement, iAttrs, controller) { ... }

这个方法执行的时候,其所有子组件的 postLink 方法也都执行完毕。也就是说,所以子组件都已经完成了初始化,包括

  • 数据绑定
  • 模板嵌入
  • scope 传入

所以此刻模板的样子已经变成了:

<my-element>
    <div class="ng-binding">
        ""
        <div ng-transclude>                
            <div class="ng-scope">Inner content</div>
        </div>
    </div>
</my-element>

执行顺序

上面理解了各步骤干的事情,这里通过代码看看他们的执行顺序,特别是在组件嵌套的情况下。

考察下面的示例代码:

<body>
    <div log='some-div'></div>
</body>

其中使用的 directive 代码为:

myApp.directive('log', function() {

    return {
        controller: function( $scope, $element, $attrs, $transclude ) {
            console.log( $attrs.log + ' (controller)' );
        },
        compile: function compile( tElement, tAttributes ) {
            console.log( tAttributes.log + ' (compile)'  );
            return {
                pre: function preLink( scope, element, attributes ) {
                    console.log( attributes.log + ' (pre-link)'  );
                },
                post: function postLink( scope, element, attributes ) {
                    console.log( attributes.log + ' (post-link)'  );
                }
            };
         }
     };  

});

在每一个阶段里都进行了日志输出,以查看其执行顺序。

对于单个 directive 而言,无疑很简单,通过最前端的图都可以猜到 log 的顺序。

some-div (compile)
some-div (controller)
some-div (pre-link)
some-div (post-link)

对于多个组件嵌套的情况,正常的 directive 是按照上面的顺序执行的,但一些 directive 譬如 ngIf,ngRepeat 或者其他使用 transclude 嵌套的组件,link 先于 compile 运行,因为这些组件是在 link 内渲染子组件的。

考察下面嵌套的示例代码:

<body>
    <div log='parent'>
        <div log='..first-child'></div>
        <div log='..second-child'></div>
    </div>
</body>

输出则是:

// The compile phase
parent (compile)
..first-child (compile)
..second-child (compile)

// The link phase   
parent (controller)
parent (pre-link)
..first-child (controller)
..first-child (pre-link)
..first-child (post-link)
..second-child (controller)
..second-child (pre-link)
..second-child (post-link)
parent (post-link)

这里,可以明显体现出之前讨论过的两个阶段,compile 阶段和 link 阶段。

所以结论是

上面解释了各方法的顺序及作用。但并没有给出建议,写 directive 时用哪个比较合理。

还是出自同一个来自 stackoverflow 提问下的回答,@Izhaki 这位 Bro 的回答可以说是相当到位,分段落每个部分回答成一个答案。

compile

每个directive 的 compile 方法只执行一次,这里主要用来对 DOM 进行操作,前提是不影响 scope 及数据绑定的 DOM 操作。

考虑下面的示例代码:

<tr ng-repeat="raw in raws">
    <my-raw></my-raw>
</tr>

假如说我们希望操作 my-raw 的 DOM 比如加个 span 进去。可以在 compile 中进行,然后让 ng-repeat 复制出许多克隆来,也可以在 ng-repeat 复制后(link 阶段),去操作每个复制出来的版本。

在数据量大的情况下,前者性能会好一些。

所以,在 compile 里面,

推荐

  • 修改 DOM 模板

不推荐

  • 绑定事件,因为这是复制之前,事件应该绑定到对应实例上
  • 操作子元素
  • 设置对属性的监听
  • 设置对 scope 的监听

controller

实例化之后 controller 便开始执行。

在 controller 里,通常

  • 定义一些可以与其他组件的 controller 共享的代码逻辑
  • 初始化 scope 变量

需要注意的一点是,如果该组件使用独立的 scope,那么是没法使用从父级 scope 继承而来的那些变量的。

在 controller 里面,

推荐

  • 书写 controller 逻辑
  • 初始化 scope 变量

不推荐

  • 操作子元素(因为有可能还没被渲染出来)

也是在实例之后开始执行。根据前文的分析,preLink 的顺序是先父组件后子组件,刚好与 postLink 相反,后者先子组件最后回溯到父组件。

一般组件很少用到 compile 方法,preLink 则更加少的情况会被用到。只某些特殊情况下会很有用,譬如:

ngModelController

不推荐

  • 操作子元素(因为有可能还没被渲染出来)

postLink 执行的时候,上面的步骤都已经完成了:数据绑定,模板代码嵌入等。所以这里是对生成好的 DOM 进行再次操作的最好时机。

推荐

  • 操作DOM(此时操作的是实例化 DOM)
  • 绑定事件
  • 与子元素进行交互
  • 设置属性的监听
  • 设置 scope 上的 监听

原始模板代码与实例模板代码

前面有提到过,这里不妨再细究一下。

  • 原始模板代码 - 用来进行复制的原始模板,如果进行了复制操作,则原始模板是不会展示在界面上的
  • 实例模板代码 - 从原始模板代码复制而来,绑定到对应组件实例,也就是每个组件实例会对应一分,它是真正被渲染到界面上的模板

譬如:

<div ng-repeat="i in [0,1,2]">
    <my-directive></my-directive>
</div>

上面代码中,原始模板代码为:

    <my-directive></my-directive>

通过ng-repeat="i in [0,1,2]"指令,这段模板会被复制成三分,复制出来的模板代码为实例模板代码,第一个都被绑定了各自的 scope,并且都会渲染到界面。

相关资源