看到这个标题,如果你对 angular 有了解,你可能会觉得很奇怪,双向绑定不是 angular 的特性之一吗?为何会出现标题的问题呢?我相信大部分刚入门 angular 的童鞋,在实践或是项目开发过程中,一定会碰到这个问题,下面谈谈让我们来问题重现,同时探讨问题出现的原因和解决办法。
问题
问题重现
如果想重现问题,有如下两个条件:
- ng-model 直接绑定的是$scope 下的变量
- 页面作用域复杂(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";
}
});
如上代码运行之后效果如下:
- 页面上文本框和 viewValue 显示 HAHA 值,说明页面读取到了作用域下的 result 初始值
- 点击设值按钮,文本框和 viewValue 和 modelValue 的值均为 Good Morning
- 修改 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。测试结果:
- 仅将 ion-content 加上,世界又不美好了
- 仅将 ion-list 加上,世界还是很美好
- 仅将 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 创建一个子作用域,测试结果如下:
- 的确存在单向 bug 问题
- 修改 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>
具体解释流程:
- 扫描 template 中,发现是一个表达式。
- 查找 scope,看作用域是三种中的哪一种,如果是第三种,要考虑绑定策略。
- 互相传递值。
传递函数的时候如何传参
<slide-list slide-list="activeView" change-slide="changeSlide(id)"></slide-list>
scope.changeSlide({id: scope.slideList[index].id});
scope: {slideList: "=", changeSlide: "&"},
注意事项:
- 方法使用&传递,同时标签中使用将骆驼命名改成中划线
- 标签中方法传参,在指令调用时,将参数集合表示成为一个 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。
link | compile 属性
这个是指令的重点。我们先了解一下编译流程。
- 标准浏览器 API 转化,将 html 转化成 dom,所以自定义的 html 标签必须符合 html 的格式
- Angular compile,搜索匹配 directive,按照 priority 排序,并执行 directive 上的 compile 方法
- Angular link,执行 directive 上的 link 方法,进行 scope 绑定及事件绑定
为什么编译的过程要分成 compile 和 link?
简单的说就是为了解决性能问题,特别是那种 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 和 link 的形式
compile
在 compile 阶段要执行的函数,返回的 function 就是 link 时要执行的 function
常用参数为 element 和 attrs,分别是 dom 元素和元素上的属性们,其它的以后细说较少使用,因为大部分 directive 是处理 dom 元素的行为绑定,而不是改变它们
function compile(element, attrs, transclude) { ... }
link:
在 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 和 link 的使用时机
compile
想在 dom 渲染前对它进行变形,并且不需要 scope 参数,想在所有相同 directive 里共享某些方法,这时应该定义在 compile 里,性能会比较好,返回值就是 link 的 function,这时就是共同使用的时候。
link
对特定的元素注册事件
需要用到 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。