angularJS指导之HTML compiler编译讲解
2017-03-02 21:06

本文是翻译文章,翻译自:angularJS之compiler高级指导

HTML compiler

note:这篇指导是针对有angularJS基础的开发人员的。如果你刚开始学习angularJS,我们推荐你先去学习angularJS教程。如果你只想想创建一个指令,我们推荐directive高级指导。如果你是想要深入学习angularJS的compilation process编译过程,那你来对地方了。


概述

        angularJS的HTML compiler允许开发者在浏览器中使用新的HTML语法。编译器允许你在任何元素或属性上绑定逻辑,甚至可以创建具有特定逻辑的新的HTML元素或者属性。angularJS通过扩展指令调用这些逻辑。

        HTML有很多结构来格式化在静态document文档中声明的HTML。例如,某个元素需要被居中,这里不需要提供指示告诉浏览器怎么将window分成两半来找到居中的位置,然后使这个居中需要排列在文本的中央。 只需要简单的添加一个“align='center'”属性在元素上就可以实现想要的效果。这就是描述性语言的魅力。

        然而,描述性语言也是受限制的,比如它不允许你在浏览器中定义新的语法。例如,在浏览器中让文本对齐在1/3处代替1/2处就没有简单的实现方法。需要的是找到一种在浏览器中定义新语法的方法。

        angularJS通过绑定任何app都可用的通用指令进行预绑定。我们也期望你将创建你的app的特定指令。这个扩展是绑定到应用的领域特定语言。

        所有的这些编译都在浏览器中执行,不需要服务器端或者执行预编译步骤。

compiler

compiler是一个angularJS服务,会遍历DOM查找属性。这个编译过程有两个阶段:

    @1:Compile-遍历DOM元素收集所需的指令,这个结果是一个link函数。

    @2:Link-结合指令和作用域产生一个动态视图。任何scope模型上的改变都反映到视图上,并且任何用户通过视图的交互反映在scope模型上。这使得scope模型成为视图的唯一来源。

一些像ng-repeat的指令遍历集合的每一项克隆DOM元素。通过编译和链接阶段改善视图展现,因为克隆的模板只需要编译一次,然后每一个克隆实例链接一次。

Directive

一个指令是一个行为,在编译阶段遇到特定的HTML结构时触发。指令可以被放置在元素名称,属性,class名称,或者注释上。这里有一些执行ng-bind执行的相同效果的例子。

<span ng-bind="exp"></span>
<span class="ng-bind: exp;"></span>
<ng-bind></ng-bind>
<!-- directive: ng-bind exp -->

一个指令就是一个函数,在编译器在DOM上遇到它时就会执行。查看directive API深入了解如何编写指令。

这里是一个指令,可以让任何元素具有可拖动性。注意<span>元素上的“draggable”属性。

script.js

angular.module('drag', [])
.directive('draggable', function($document) {
  return function(scope, element, attr) {
    var startX = 0, startY = 0, x = 0, y = 0;
    element.css({
     position: 'relative',
     border: '1px solid red',
     backgroundColor: 'lightgrey',
     cursor: 'pointer',
     display: 'block',
     width: '65px'
    });
    element.on('mousedown', function(event) {
      // Prevent default dragging of selected content
      event.preventDefault();
      startX = event.screenX - x;
      startY = event.screenY - y;
      $document.on('mousemove', mousemove);
      $document.on('mouseup', mouseup);
    });

    function mousemove(event) {
      y = event.screenY - startY;
      x = event.screenX - startX;
      element.css({
        top: y + 'px',
        left:  x + 'px'
      });
    }

    function mouseup() {
      $document.off('mousemove', mousemove);
      $document.off('mouseup', mouseup);
    }
  };
});


index.html

<span draggable>Drag ME</span>

效果如下:

blob.png

实例可以查看:angularJS draggable指令

元素上的draggable属性给元素添加了新行为。我们按照和HTML的规则相似的方法扩展浏览器的语法词汇。

理解视图

大多数其他的模版系统消化一个字符串模板,并将它和数据结合,返回一个新字符串。这个结果然后通过innerHTML注入到元素中。

One_Way_Data_Binding.png

这意味着数据的任何改变都需要重新和模板结合然后通过innerHTML注入到DOM元素中。这样会导致一些问题:

    @1:读取用户的输入并且和数据结合。

    @2:由于重写中断用户的输入。

    @3:管理整个更新过程。

    @4:缺乏行为逻辑的表现形式。

angularJS和他们不同。angularJS编译器处理DOM,而不是字符串模板。结果是一个link函数,在结合scope模型时反映在视图上。视图和scope模型的绑定是透明的。开发者不需要指定任何特殊的调用来更新视图。并且因为没有使用innerHTML,你不会意外的中断用户的输入。此外,angularJS指令不只可以包含文本绑定,还可以进行逻辑结构的绑定。

angularJS趋向于创建一个稳定的DOM。这个DOM元素实例绑定到一个模板项的实例,在绑定的生命周期中不会改变。这意味着代码可以获取元素并且注册事件回调函数,而且知道元素的引用不因因为模板数据的结合而销毁失效。

如何编译指令

这是非常重要的,注意angularJS操作DOM元素而不是字符串。通常,你不注意这种限制,因为当一个页面加载后,web浏览器自动将HTML分析成DOM元素。

HTML编译发生在三个阶段:

    @1:$compile遍历DOM匹配指令。

        如果编译器找到一个元素匹配一个指令,然后指令会添加到匹配元素的指令列表。一个元素可以匹配多个指令。

    @2:一旦匹配元素的所有指令已经被确认,编译器会通过"priority"优先级对指令进行排序。

        每一个指令的“compile”函数都被运行。每一个“compile”函数都有一个修改DOM的机会。每一个“compile”方法返回一个“link”函数。这些函数组成一个“combined”组合的link函数,执行每一个指令都会返回一个“link”函数。

    @3:通过在准备阶段调用结合的链接函数,$compile链接模板和作用域。依次调用单个指令的链接函数,在元素上注册监听器并且根据各个指令的配置通过scope进行$watch启动脏检查。

这是DOM和scope动态绑定的结果。基于这一点,编译的scope中的model上的一个改变会反映在DOM上。

下面是使用$compile服务的相应代码。这可以帮助你理解angularJS内部做了什么。

var $compile = ...; 
// 添加你的代码
var scope = ...;
var parent = ...; 
// 编译模板时被添加的DOM元素
var html = '<div ng-bind="exp"></div>';
// Step 1: 解析HTML为DOM元素
var template = angular.element(html);
// Step 2: 编译模板
var linkFn = $compile(template);
// Step 3: 连接模板和作用域
var element = linkFn(scope);
// Step 4: 添加DOM元素
parent.appendChild(element);


编译和链接之间的区别

这一点你可能奇怪编译过程为什么分成编译和链接两个阶段。简单的回答是编译和链接阶段分开是必要的,任何时间的模型上的改变都可能引起DOM结构的改变。

指令包含一个编译函数是优秀的,因为大多数指令在特定相关指令上运行,而不是覆盖所有的DOM结构。

指令通常包含一个链接函数。一个链接函数允许指令在特定的克隆元素实例上注册监听器,同时也从scope复制内容到DOM元素中。


最佳实践:任何可以在指令实例之间共享的操作都应该由于性能原因而被转移到编译函数中。


一个对比compile和link的例子

为了便于理解,让我们看看ng-repeat的真实例子:

Hello {{user.name}}, you have these actions:
<ul>
  <li ng-repeat="action in user.actions">
    {{action.description}}  
  </li>
</ul>


当上门的例子被编译,编译器访问每一个节点查找指令。

{{user.name}}匹配插值指令,ng-repeat匹配ngRepeat指令。

但是ngRepeat指令有一个缺陷。

它需要能为user.actions中每一个action创建一个新的<li>元素。这最初似乎简单,但当你考虑之后可能在“user.actions”添加一个条目时,它将变得复杂。这意味着它需要为复制行为保留一个<li>元素的原始副本。

当新action被添加时,<li>模板需要被拷贝添加到 ul 中。但是单纯的拷贝<li>元素是不够的。它还需要通过它的指令编译<li>,就像{{action.description}},计算对应的作用域。

解决这个问题的一个简单方法就是简单的插入<li>元素的一个拷贝然后编译它。这种解决方法会在每一个元素上编译,这样我们会做很多重复工作。特别的,我们在拷贝它之前每次都需要遍历<li>查找对应的指令。这将导致编译过程缓慢,当插入新元素时,应用的响应速度迟钝。

解决方案就是将编译过程分成两个阶段:

编译阶段,所有的指令被识别并且按照优先级进行排序,链接阶段,连接特定的scope实例和<li>的实例。


note: Link表示在DOM元素上设置监听器,并且在scope上设置$watch,并且保持两者的同步。


ngRepeat通过阻止<li>元素的编译过程,所以它可以获取原始的拷贝并且自己操作添加和删除DOM节点。

ngRepeat对<li>的编译过程分开。<li>元素编译过程的结果是一个link函数,包含<li>元素上的所有指令,准备链接到特定的<li>元素的克隆。

在运行时,ngRepeat监测表达式,并且当数组中添加条目时克隆<li>元素,在克隆的<li>元素上创建一个新的scope并且在克隆的<li>元素上调用link函数。

理解嵌入指令的scope如何运行

大多指令使用的原因之一是创建一个可复用的组件。

下面是用假代码展示简单的对话框组件如何运行。

<div>
  <button ng-click="show=true">show</button>

  <dialog title="Hello {{username}}."
          visible="show"
          on-cancel="show = false"
          on-ok="show = false; doSomething()">
     Body goes here: {{username}} is {{title}}.  
  </dialog>
</div>


点击“show”按钮将会打开对话框。对话框有一个标题,数据通过{{username}}绑定,并且它有一个body可以让我们嵌入到对话框中。

这里是一个例子,dialog组件模板的定义可能像下面这样:

<div ng-show="visible">
  <h3>{{title}}</h3>
  <div class="body" ng-transclude></div>
  <div class="footer">
    <button ng-click="onOk()">Save changes</button>
    <button ng-click="onCancel()">Close</button>
  </div>
</div>


这个不会正确的渲染,除非我们做一些scope戏法。

我们需要解决的第一个问题是对话框模板希望“title”被定义。但我们希望模板作用域的title属性是插入到<dialog>属性上的内容(例如,“Hello,{{username}}”)。更进一步,按钮上的“onOk”和“onCancel”方法在scope中定义。这限制组件的灵活性。为了解决对应的问题,我们使用scope创建本地变量,模板如下:

scope: {
  title: '@',             // the title uses the data-binding from the parent scope
  onOk: '&',              // create a delegate onOk function
  onCancel: '&',          // create a delegate onCancel function
  visible: '='            // set up visible to accept data-binding
}


在小部件作用域创建本地变量产生了两个问题:

    @1:隔离-如果用户忘记这小部件上设置title属性,对话框模板将绑定到父作用域属性上。这是不可预测的并且不方便的。

    @2:嵌入包含-嵌入的DOM可以看作组件的内容,嵌入元素需要的绑定数据可能会被覆盖。在外面的例子中组件的title属性会覆盖嵌入元素的title属性。

为了解决独立作用域缺陷的问题,指令声明一个新的独立作用域。一个独立作用域不会原型继承它的父作用域,因此我们不需要担心意外的覆盖任何属性。

然而独立作用域导致了一个新问题:如果嵌入的DOM是组件独立作用域的一个子类,那么他将不能绑定任何东西。因此,在组件为它的本地变量创建一个独立作用域时,嵌入scope是原始scope的子类。这使得嵌入作用域和独立作用域是兄弟关系。

这可能看起来特别复杂,但他给了组件使用者和开发者最少的疑问。

因此最令的最终定义应该类似这样:

transclude: true,
scope: {
    title: '@',     // the title uses the data-binding from the parent scope
    onOk: '&',      // create a delegate onOk function
    onCancel: '&',  // create a delegate onCancel function
    visible: '='    // set up visible to accept data-binding
    },
restrict: 'E',
replace: true

两次编译,以及如何避免它

当DOM元素已经编译的一部分又要进行编译会导致两次编译。这是一个不想要的效果并且会导致指令行为错乱,展现的bug,和内存泄漏。一个常见的场景是,当指令在指令元素的link函数中调用$compile。在下面错误的例子中,指令在一个按钮上通过ngClick添加一个mouseover行为:

angular.module('app')
.directive('addMouseover', function($compile) {
  return {
    link: function(scope, element, attrs) {
      var newEl = angular.element('<span ng-show="showHint"> My Hint</span>');
      element.on('mouseenter mouseleave', function() {
        scope.$apply('showHint = !showHint');
      });

      attrs.$set('addMouseover', null); // To stop infinite compile loop
      element.append(newEl);
      $compile(element)(scope); // Double compilation
    }
  }})


第一眼,它看起来像移除原始的addMouseover属性使得例子能正常运行。然而,如果指令元素或者它的子元素有其他指令声明,他们会被再一次编译和链接,因为编译器不记录元素上添加的指令。

这会导致不确定的行为,例如,ngClick或者其他的事件回调会被再一次绑定。它也会降低展示效果,因为scope上对文本插值的监测会增加两倍。

两次绑定应该被避免,在上面的例子中,只有新添加的元素需要编译:

angular.module('app')
.directive('addMouseover', function($compile) {
  return {
    link: function(scope, element, attrs) {
      var newEl = angular.element('<span ng-show="showHint"> My Hint</span>');
      element.on('mouseenter mouseleave', function() {
        scope.$apply('showHint = !showHint');
      });

      element.append(newEl);
      $compile(newEl)(scope); // Only compile the new element
    }
}})


另一个场景是以程序代码的方式给一个已经编译的元素添加一个指令,然后会再一次执行编译。看下面的错误例子:

<input ng-model="$ctrl.value" add-options>
angular.module('app')
.directive('addOptions', function($compile) {
  return {
    link: function(scope, element, attrs) {
      attrs.$set('addOptions', null) // To stop infinite compile loop
      attrs.$set('ngModelOptions', '{debounce: 1000}');
      $compile(element)(scope); // Double compilation
    }
  }});

如果那样的话,需要中断元素的初始化编译:

    @1:给你的指令添加"terminal"属性并且设置比二次编译的指令高的"priority"优先级。在这个例子中,编译器只会编译拥有100或以上“priority”的指令。

    @2:在指令函数中,添加任何其他的指令属性到模板上。

    @3:编译元素,但限制最高的优先级,所以其他意见编译的指令(包括addOptions指令)不会再被编译。

    @4:在link函数中,连接编译的元素和元素的scope作用域。

angular.module('app')
.directive('addOptions', function($compile) {
  return {
    priority: 100, // ngModel 的优先级是1
    terminal: true,
    compile: function(templateElement, templateAttributes) {
      templateAttributes.$set('ngModelOptions', '{debounce: 1000}');

      // 第三个属性是最大的优先级. 只有指令的优先级 priority < 100 才会被编译,
      // 因此我们不需要移除此属性
      var compiled = $compile(templateElement, null, 100);

      return function linkFn(scope) {
        compiled(scope) // 将编译的元素连接到scope上。
      }
    }
}});


原创文章,转载请注明来自:妹纸前端-www.webfront-js.com.
阅读(1896)
辛苦了,打赏喝个咖啡
微信
支付宝
妹纸前端
妹纸前端工作室 | 文章不断更新中
京ICP备16005385号-1