Better

Ethan的博客,欢迎访问交流

ng-model 作用在 input 标签之上数据单向传递的问题

看到这个标题,如果你对 angular 有了解,你可能会觉得很奇怪,双向绑定不是 angular 的特性之一吗?为何会出现标题的问题呢?我相信大部分刚入门 angular 的童鞋,在实践或是项目开发过程中,一定会碰到这个问题,下面谈谈让我们来问题重现,同时探讨问题出现的原因和解决办法。

问题

问题重现

如果想重现问题,有如下两个条件:

  1. ng-model 直接绑定的是$scope 下的变量
  2. 页面作用域复杂(ionic 页面中基本出现,因为 ion-content 缘故)

接下来直接用代码演示:

html 代码

<ion-view view-title="Account">
  <ion-content>
    <ion-list>
      <input class="item-input" style="border: 1px solid #000;" type="text" ng-model="result">
      viewValue:{{result}}
      </ion-item>
      <ion-item>
        <button ng-click="setResult()">设值</button>
        <button ng-click="showResult()">ModelValue</button>
      </ion-item>
    </ion-list>
  </ion-content>
</ion-view>

JS 代码

  .controller('AccountCtrl', function ($scope) {

    $scope.result = 'HAHA';

    $scope.showResult = function () {
      console.log($scope.result);
    }

    $scope.setResult = function(){
      $scope.result = "Good Morning";
    }

  });

如上代码运行之后效果如下:

  1. 页面上文本框和 viewValue 显示 HAHA 值,说明页面读取到了作用域下的 result 初始值
  2. 点击设值按钮,文本框和 viewValue 和 modelValue 的值均为 Good Morning
  3. 修改 input 文本框的值,viewValue 值同步显示文本框的值,但此时 modelValue 依旧为 Good Morning,同时点击设值按钮并不会改变文本框和 viewValue 的值

看到这里问题就很奇怪了,为什么我修改了文本框的值,不能反应到 modelView 呢,同时修改 modelValue 也不能反应到 viewValue 了,我的乖乖,这怎么回事呢!

问题解决

解决问题其实十分简单,就算不知道原因,也可以解决。那就是不直接绑定变量,而是绑定变量的属性,那么问题不会出现了。

$scope.init = { result: "HAHA" };

// ng-model = 'init.result'

问题探索

为了便于分析,我将页面简化,去掉出 ion-view 之外的所有非原生支持的 html 标签,在例子上表现为 ion-content、ion-list 和 ion-item。重新测试发现世界都美好了,问题不存在了。我们知道指令是可以选择创建自己的子作用域的,会不会和这个有关呢?分别查看三者的源码,ion-content 和 ion-item 的 scope 属性值为 true,而 ion-list 并没有声明 scope 值,也就是默认值 false。测试结果:

  1. 仅将 ion-content 加上,世界又不美好了
  2. 仅将 ion-list 加上,世界还是很美好
  3. 仅将 ion-item 加上,世界又不美好了

测试结果符合预期,就是因为作用域的关系。一开始能读取到 modelView 的值,就是因为在子作用域中不存在 result 值,因为他会一层一层从父作用域上找,但当你操作 input 值时,他会在子作用域 scope 上创建 result 值并同步结果,此时父子作用域就断开了联系,因为在子作用域存在时,肯定子作用域优先。

可这一切都是我们猜想的呀,让我想起了 ng-controller 指令,简化 HTML 代码如下:

<ion-view view-title="Account" style="top: 60px;">
  <div ng-controller="test">
    <input class="item-input" style="border: 1px solid #000;" type="text" ng-model="result">
    viewValue:{{result}}
    <button ng-click="setResult()">设值</button>
    <button ng-click="showResult()">ModelValue</button>
    <button ng-click="showChildResult()">childValue</button>
  </div>
</ion-view>

JS 增加 test controller

.controller('test', function ($scope) {
    $scope.showChildResult=function () {
     console.log($scope.result);
    }
})

此时我们简化了 HTML 的 ionic 相关组件,增加一个 div 并通过 ng-controller 创建一个子作用域,测试结果如下:

  1. 的确存在单向 bug 问题
  2. 修改 input 的值,不会影响 modelView 的值,那么那是否双向绑定到了 test 作用域呢,点击 childValue 按钮,结果符合预期,在 test 作用于下,值和 input 的值是同步的

到这里结果已经很明显了,如果还不好理解,下面通过一个原型继承的例子帮助大家理解。

原型继承

我们直接简单粗暴的演示一下:

var father = { name: "sky" };
var son = { age: 20, __proto__: father };

// 此时son可以访问到name值
console.log(son.name); // sky

// 修改son.name
son.name = "skysky";

// 再次打印son和father的name
console.log(son.name); // skysky
console.log(father.name); // sky

// 可以看到son修改name值再也影响不到father的name值啦!

指令

bug 演示完毕啦,在结尾总结一下 angular1.x 中指令的基本用法。

指令定义

Angular 指令定义和定义其他模块,比如 service 等语法是一样的,具体格式如下:

directive.directive(指令名称, function() {
  return {};
});

在这里需要注意的是指令名称这里有个坑,指令名称使用骆驼命名法,但是实际的标签或者属性等是使用-隔开的。

属性详解

restrict 属性

restrict 属性用来声明指令时属性何种类型的,具体解释见下图。可以混合使用。

字母 声明风格 示例
E 元素 <my-menu title="products"></my-menu>
A 属性 <div my-menu="products"></div>
C 样式类 <div class="my-menu:products"></div>
M 注释 <!-- directive:my-menu products -->

template|templateUrl 属性

表示模板,一般在表示模版,一般在 restrict(约束)为 E 的时候使用,表示替换的文本。

这两个属性用来制定标签的模板,template 是在带替换的标准 html 代码比较简短的时候使用。templateUrl 在带替换的标准 html 代码比较长的时候使用,这里是内联目标【通过 script 创建的模板】,也可以是单独的 html 文件。

replace 属性

这个属性表示是否需要替代掉自定义标签。

transclude 属性

使用 transclude 属性就是保证自定义标签的子标签内容依旧存在,但是需要声明一个容器,属性有 ng-transclude,这样原本的内容就会放在这个容器下,并且原来的内容会自动添加上 ng-scope 的 class,如果没有提供容器,那么依旧不会保存,提供了容器,取值为 false,也不会保存。

scope 属性

scope 属性在自定义指令中十分重要,是父子作用域沟通的桥梁,取值有如下表所示。

取值 说明
false 默认值。使用父作用域作为自己的作用域
true 新建一个作用域,该作用域继承父作用域
javascript 对象 与父作用域隔离,并指定可以从父作用域访问的变量

取值为 false 或者 true 不细看没什么区别,但是本质上区别很大。取值为 false,使用父作用域作为自己的作用域。也就是说子作用域没有创建新的作用域,子作用域修改或添加的值会反映到父作用域上,因为本质上两者是同一个作用域

取值为 true,继承父作用域并创建自己的作用域,修改自身的值不会反应到父作用域上,可以通过scope.$parent访问父作用域。

上面理解之后,最有用的是第三种,赋值为一个对象,可以用键值对来显示的指明从父作用域中使用属性的方式。此时是创建了一个隔离作用域,并不是完全隔离,可以手动搭建桥梁来放行某些元素。

特别需要注意的是:我们既可以将父控制器的数据传递到子控制器,也可以将子控制器数据传递到父控制器,从而实现互通。这才是双向绑定的意义,为了避免浪费时间:一定要检查,在控制器中骆驼命名法,标签中是中划线!!!

具体如何放行呢?这里涉及到一个绑定策略问题。 | 取值 | 说明 | | :--------: | :----: | | @|@name | 传递一个字符串来作为属性的值。【值传递,单向绑定】,字符串只能用@传递,否则会报错! | | =|=name | 使用父作用域的一个属性,绑定数据到指令属性中。【引用,双向绑定】 | | &|&name | 使用父作用域中的一个函数,可在指令中调用。【表达式】 |

作用:可以控制父作用域的值可以在子指令模板中使用。对可以使用父作用域的值进行限定,如果使用 true 或 false,则可以访问父作用域中所有的值。

第三种方式比较复杂,下面列出一个简单的例子。

// 标签使用
<say-hello speak=”content”></say-hello>
// 具体指令定义
scope:{cont:”=speak”}
// 模板文件
<div>{{cont}}</div>

具体解释流程:

  1. 扫描 template 中,发现是一个表达式。
  2. 查找 scope,看作用域是三种中的哪一种,如果是第三种,要考虑绑定策略。
  3. 互相传递值。

传递函数的时候如何传参

<slide-list slide-list="activeView" change-slide="changeSlide(id)"></slide-list>
scope.changeSlide({id: scope.slideList[index].id});
scope: {slideList: "=", changeSlide: "&"},

注意事项:

  1. 方法使用&传递,同时标签中使用将骆驼命名改成中划线
  2. 标签中方法传参,在指令调用时,将参数集合表示成为一个 json 对象,方法参数和对象的属性一一对应,否则会出现报错或无法调用

使用总结

第三种方式的作用在于对于父类作用域的一些属性放行,我觉得最直观的体验在于可以对指令模板的参数进行动态化,更加灵活。实践例子如下:

return {
  restrict: "E",
  templateUrl: "lib/hydContactsNav/contacts_nav.html",
  replace: false,
  //scope:false,
  scope: { contactsData: "=hydContactsData" },
  link: function(scope, element, attrs, controller) {}
};

Scope 如此定义之后,可以在模板中使用 contactsData 属性,同时在 link 中的绑定方法中也是可以使用的,因此他已经属于这个指令的作用域中。

模板文件使用

<ul class="alpha_sidebar" on-drag="goList($event)" on-release="hidePromptBox()" on-touch="goListByTouch($event)">
  <li ng-repeat="contacts in contactsData" data-id="{{contacts.id}}">{{contacts.firstCode}}</li>
  <div class="promptBox-hydNav"><h3></h3></div>
</ul>

指令的使用

<hyd-contacts-nav hyd-delegate-handle="contactsScroll" hyd-contacts-data="friendsArray"></hyd-contacts-nav>

这里需要注意的是前台标签中的-在 js 代码中要使用骆驼命名法。也就是说 hyd-contacts-data 属性对应 scope 定义中的 hydContactsData。

这个是指令的重点。我们先了解一下编译流程。

  1. 标准浏览器 API 转化,将 html 转化成 dom,所以自定义的 html 标签必须符合 html 的格式
  2. Angular compile,搜索匹配 directive,按照 priority 排序,并执行 directive 上的 compile 方法
  3. Angular link,执行 directive 上的 link 方法,进行 scope 绑定及事件绑定

简单的说就是为了解决性能问题,特别是那种 model 变化会影响 dom 结构变化的,而变化的结构还会有新的 scope 绑定及事件绑定,比如 ng-repeat。

Link:该方法在指令中扮演着重要的角色。它负责执行 DOM 操作和注册事件监听器等。link 方法包含以下参数:

  • scope: 指令 Scope 的引用。scope 变量在初始化时是不被定义的,link 方法会注册监视器监视值变化事件。
  • element: 包含指令的 DOM 元素的引用, link 方法一般通过 jQuery 操作实例(如果没有加载 jQuery,还可以使用 Angular's jqLite )。
  • controller: 在有嵌套指令的情况下使用。这个参数作用在于把子指令的引用提供给父指令,允许指令之间进行交互 controller: 在有嵌套指令的情况下使用。这个参数作用在于把子指令的引用提供给父指令,允许指令之间进行交互
  • 注意,当调用 link 方法时, 通过值传递("@")的 scope 变量将不会被初始化,它们将会在指令的生命周期中另一个时间点进行初始化,如果你需要监听这个事件,可以使用 scope.$watch 方法。
compile

在 compile 阶段要执行的函数,返回的 function 就是 link 时要执行的 function

常用参数为 element 和 attrs,分别是 dom 元素和元素上的属性们,其它的以后细说较少使用,因为大部分 directive 是处理 dom 元素的行为绑定,而不是改变它们

function compile(element, attrs, transclude) { ... }

在 link 阶段要执行的函数,这个属性只有当 compile 属性没有设置时才生效

常用参数为 scope,element 和 attrs,分别是当前元素所在的 scope,dom 元素和元素上的属性们,其它的以后细说。directive 基本上都会有此函数,可以注册事件,并与 scope 相绑。

对于 attr 的属性值,如果是方法,我们调用他方法需要使用$apply 方法,scope.$apply(attr.loaddatafn);

判定事件可以使用 bind 方法:element.bind()

function link(scope, element, attrs, controller) { ... }
compile

想在 dom 渲染前对它进行变形,并且不需要 scope 参数,想在所有相同 directive 里共享某些方法,这时应该定义在 compile 里,性能会比较好,返回值就是 link 的 function,这时就是共同使用的时候。

对特定的元素注册事件

需要用到 scope 参数来实现 dom 元素的一些行为。

注意:当调用 link 方法时, 通过值传递("@")的 scope 变量将不会被初始化,它们将会在指令的生命周期中另一个时间点进行初始化,如果你需要监听这个事件,可以使用 scope.$watch 方法。

controller | require 属性

Controller 用于定义指令对外提供的接口。

为这个指令定义一些方法或属性。字符或函数,当为字符串时,会以字符串的名字来查找注册在应用中的控制器的构造函数。我们可以将任意的可以被注入的 Angularjs 服务传递给控制器,在控制器中也有一些特殊的服务可以被注入到指令中,如:controller:function($scope, $element, $attrs $transclude)。$transclude 嵌入链接函数会与对应的嵌入作用域进行绑定,transclude 链接函数是实际被执行的用来克隆元素和操作 DOM 的函数。指令的控制器和 link 函数可以进行互换,控制器主要用来提供可以在指令间复用的行为,但链接函数只能在当前指令中定义行为,且无法在指令间复用。

方法

controller:function controllerContructor($scope,$element,$attrs,$transclude)

构造器函数,将来可以构造出一个实例传递给他的指令。

接口暴露:controller 中如何暴露接口呢,因为是构造函数 new 出来的,通过 this 关键字即可。

Require 指明需要依赖的其他指令。

值为字符串,就是所依赖的指令的名字。为了使寻找指令更加轻松,可以添加^表示在父节点上寻找,不添加表示在节点本身寻找,?告诉系统即使没找到,也不要抛出异常。字段的作用是用于指令之间的相互交流。引入其他指令,在 link 方法中表现为最后一个参数,参数可以被设置为字符串或数组,字符串代表另外一个指令的名字,require 会将控制器注入到其值所指定的指令中,并作为当前指令的链接函数的第四个参数。require 参数的值可以用下面的前缀进行修饰,这会改变查找控制器时的行为: ? 如果在当前指令中没有找到所需要的控制器,会将 null 作为传给 link 函数的第四个参数。如果添加了 ^ 前缀,指令会在上游的指令链中查找 require 参数所指定的控制器。 ?^ 将前面两个选项的行为组合起来,我们可选择地加载需要的指令并在父指令链中进行查找。如果没有前缀,指令将会在自身所提供的控制器中进行查找,如果没有找到任何控制器就抛出一个错误。

举个简单的例子,假如我们现在需要编写两个指令,在linking函数中有很多重合的方法,为了避免重复自己(著名的DRY原则),我们可以将这个重复的方法写在第三个指令的 controller中,然后在另外两个需要的指令中require这个拥有controller字段的指令,最后通过 linking 函数的第四个参数就可以引用这些重合的方法。

Eg:require: '?^common',

指令的控制器和 link 函数可以进行互换,控制器主要用来提供可以在指令间复用的行为,但链接函数只能在当前指令中定义行为,且无法在指令间复用。

自定义指令 controller 比 link 要先执行,自定义指令 link 函数比父容器 controller 函数要先执行 一开始就执行了,所以直接内部通过 scope 访问父控制器属性,是会访问不到,但是如果通过事件进行访问却可以,这是一个先后顺序的问题。

这里显示一个例子

var test = angular.module("starter.test", []);
test.directive("accordion", function() {
  return {
    restrict: "E",
    template: "<div ng-transclude></div>",
    replace: true,
    transclude: true,
    controller: function() {
      var expanders = [];
      this.gotOpended = function(selectedExpander) {
        angular.forEach(expanders, function(e) {
          if (selectedExpander != e) {
            e.showText = false;
          }
        });
      };

      this.addExpander = function(e) {
        expanders.push(e);
      };
    }
  };
});

test.directive("expander", function() {
  return {
    restrict: "E",
    templateUrl: "expanderTemp.html",
    replace: true,
    transclude: true,
    require: "^?accordion",
    scope: {
      title: "=etitle"
    },
    link: function(scope, element, attris, accordionController) {
      scope.showText = false;
      accordionController.addExpander(scope);
      scope.toggleText = function() {
        scope.showText = !scope.showText;
        accordionController.gotOpended(scope);
      };
    }
  };
});

模板文件

<script type="text/ng-template" id="expanderTemp.html">
  <div class="mybox">
    <div class="mytitle" ng-click="toggleText()">
      {{ mytitle }}
    </div>
    <div ng-transclude ng-show="showText" />
  </div>
</script>

指令使用

<accordion>
  <expander ng-repeat="expander in expanders" etitle="expander.title" style="display: block">{{expander.text}}</expander>
</accordion>

ngModelController 详解

在自定义 Angular 指令时,其中有一个叫做 require 的字段,这个字段的作用是用于指令之间的相互交流。举个简单的例子,假如我们现在需要编写两个指令,在 linking 函数中有很多重合的方法,为了避免重复自己(著名的 DRY 原则),我们可以将这个重复的方法写在第三个指令的 controller 中,然后在另外两个需要的指令中 require 这个拥有 controller 字段的指令,最后通过 linking 函数的第四个参数就可以引用这些重合的方法。

controller 的用法分为两种情形,一种是 require 自定义的 controller,由于自定义 controller 中的属性方法都由自己编写,使用起来比较简单;另一种方法则是 require AngularJS 内建的指令,其中大部分时间需要 require 的都是 ngModel 这个指令。很多时候,由于我们对 ngModel 中内建的方法和属性不熟悉,在阅读和编写代码时会有一些困难。今天我们的目的就是详细的介绍 ngModel 中的内建属性和方法,相信在认真的阅读完本文之后,你一定能够熟练的在指令中 require ngModel。

在 Angular 应用中,ng-model 指令时不可缺少的一个部分,它用来将视图绑定到数据,是双向绑定魔法中重要的一环。ngModelController 则是 ng-model 指令中所定义的 controller。这个 controller 包含了一些用于数据绑定,验证,CSS 更新,以及数值格式化和解析的服务。它不用来进行 DOM 渲染或者监听 DOM 事件。与 DOM 相关的逻辑都应该包含在其他的指令中,然后让这些指令来试用 ngModelController 中的数据绑定功能。

下面,我们将用一个例子来说明如何在自定义指令中 require ngModel。在这里例子中,我们使用 HTML5 中的 contenteditable 属性来制作了一个简单的编辑器指令,同时将在指令定义中 require 了 ngModel 来进行数据绑定。

html

<div contenteditable=”true” name="myWidget" ng-model="userContent" strip-br="true">Change me!</div>

使用指令来使得 ng-model 可以双向绑定!

// 指定UI的更新方式
ngModel.$render = function() {
  element.html(ngModel.$viewValue || "");
};

// 监听change事件来开启绑定
element.on("blur keyup change", function() {
  scope.$apply(read);
});
read(); // 初始化
// 将数据写入model
function read() {
  var html = element.html();
  // 当我们清空div时浏览器会留下一个<br>标签
  // 如果制定了strip-br属性,那么<br>标签会被清空
  if (attrs.stripBr && html == "<br>") {
    html = "";
  }
  ngModel.$setViewValue(html);
}

方法

  • $render();
    • 当视图需要更新的时候会被调用。使用ng-model的指令应该自行实现这个方法。
  • $isEmpty(value);
    • 该方法用于判断输入值是否为空。
    • 例如,使用ngModelController的指令需要判断其中是否有输入值的时候会使用该方法。该方法可用来判断值是否为undefined,'',null或者NaN。 你可以根据自己的需要重载该方法。
  • $setValidity(validationErrorKey, isValid);
    • 该方法用于改变验证状态,以及在控制变化的验证标准时通知表格。 这个方法应该由一个验证器来调用。例如,一个解析器或者格式化函数。
  • $setPristine();
    • 该方法用于设置控制到原始状态。 该方法可以移除'ng-dirty'类并将控制恢复到原始状态('ng-pristine'类)。
  • $cancelUpdate();
    • 该方法用于取消一次更新并重置输入元素的值以防止$viewValue发生更新,它会由一个pending debounced事件引发或者是因为input输入框要等待一些未来的事件。
    • 如果你有一个使用了ng-model-options指令的输入框,并为它设置了debounced事件或者是类似于blur的事件,那么你可能会碰到在某一段时间内输入框中值和ngModel的$viewValue属性没有保持同步的情况。
    • 在这种情况下,如果你试着在debounced/future事件发生之前更新ngModel的$modelValue,你很有可能遇到困难,因为AngularJS的dirty cheching机制实际上并不会分辨一个模型究竟有没有发生变化。
    • $cancelUpdate()方法应该在改变一个输入框的model之前被调用。记住,这很重要因为这能够确保输入字段能够被新的model值更新,而pending操作将会被取消。
  • $setViewValue(value, trigger)方法
    • 该方法用来更新视图值。这个方法应该在一个视图值发生变化时被调用,一般来说是在一个DOM事件处理函数中。例如,input和select指令就调用了这个函数。
    • 这个方法将会更新$viewValue属性,然后在$pasers中通将这个值传递给每一个函数,其中包括了验证器。这个值从$parsers输出后,将会被用于$modelValue以及ng-model属性中的表达式。
    • 最后,所有位于$viewChangeListeners列表中注册的监听器将会被调用。

属性

  • $viewValue:视图中的实际值
  • $modelValue:model中的值,它金额控制器绑定在一起
  • $parsers:将要执行的函数的数组,无论什么时候控制器从DOM中读取了一个值,它都将作为一个管道。其中的函数依次被调用,并将结果传递给下一个。最后出来的值将会被传递到model中。其中将包括验证和转换值的过程。对于验证步骤,这个解析器将会使用$setValidity方法,对于不合格的值将返回undefined。
  • $formatters:一个包含即将执行函数的数组,无论什么时候model的值发生了变化,它都会作为一个管道。其中的每一个函数都被依次调用,并将结果传递给下一个函数。该函数用于将模型传递给视图的值进行格式化。
  • $viewChangeListeners:只要视图的值发生变化,其中的函数就会被执行。其中的函数执行并不带参数,它的返回值也会被忽略。它可以被用在额外的#watches中。
  • $error:一个包含所有error的对象。
  • $pristine:如果用户还没有进行过交互,值是true。
  • $dirty:如果用户已经进行过交互,值是true。
  • $valid:如果没有错误,值是true。
  • $invalid:如果有错误,值是true。


留言