feat($compile): simplify isolate scope bindings

Changed the isolate scope binding options to:
  - @attr - attribute binding (including interpolation)
  - =model - by-directional model binding
  - &expr - expression execution binding

This change simplifies the terminology as well as
number of choices available to the developer. It
also supports local name aliasing from the parent.

BREAKING CHANGE: isolate scope bindings definition has changed and
the inject option for the directive controller injection was removed.

To migrate the code follow the example below:

Before:

scope: {
  myAttr: 'attribute',
  myBind: 'bind',
  myExpression: 'expression',
  myEval: 'evaluate',
  myAccessor: 'accessor'
}

After:

scope: {
  myAttr: '@',
  myBind: '@',
  myExpression: '&',
  // myEval - usually not useful, but in cases where the expression is assignable, you can use '='
  myAccessor: '=' // in directive's template change myAccessor() to myAccessor
}

The removed `inject` wasn't generaly useful for directives so there should be no code using it.
This commit is contained in:
Misko Hevery 2012-06-06 13:58:10 -07:00 committed by Igor Minar
parent 5c95b8cccc
commit c3a41ff9fe
5 changed files with 292 additions and 217 deletions

View file

@ -321,34 +321,32 @@ compiler}. The attributes are:
parent scope. <br/>
The 'isolate' scope takes an object hash which defines a set of local scope properties
derived from the parent scope. These local properties are useful for aliasing values for
templates. Locals definition is a hash of normalized element attribute name to their
corresponding binding strategy. Valid binding strategies are:
templates. Locals definition is a hash of local scope property to its source:
* `attribute` - one time read of element attribute value and save it to widget scope. <br/>
Given `<widget my-attr='abc'>` and widget definition of `scope: {myAttr:'attribute'}`,
then widget scope property `myAttr` will be `"abc"`.
* `@` or `@attr` - bind a local scope property to the DOM attribute. The result is always a
string since DOM attributes are strings. If no `attr` name is specified then the local name
and attribute name are same. Given `<widget my-attr="hello {{name}}">` and widget definition
of `scope: { localName:'@myAttr' }`, then widget scope property `localName` will reflect
the interpolated value of `hello {{name}}`. As the `name` attribute changes so will the
`localName` property on the widget scope. The `name` is read from the parent scope (not
component scope).
* `evaluate` - one time evaluation of expression stored in the attribute. <br/> Given
`<widget my-attr='name'>` and widget definition of `scope: {myAttr:'evaluate'}`, and
parent scope `{name:'angular'}` then widget scope property `myAttr` will be `"angular"`.
* `=` or `=expression` - set up bi-directional binding between a local scope property and the
parent scope property. If no `attr` name is specified then the local name and attribute
name are same. Given `<widget my-attr="parentModel">` and widget definition of
`scope: { localModel:'=myAttr' }`, then widget scope property `localName` will reflect the
value of `parentModel` on the parent scope. Any changes to `parentModel` will be reflected
in `localModel` and any changes in `localModel` will reflect in `parentModel`.
* `bind` - Set up one way binding from the element attribute to the widget scope. <br/>
Given `<widget my-attr='{{name}}'>` and widget definition of `scope: {myAttr:'bind'}`,
and parent scope `{name:'angular'}` then widget scope property `myAttr` will be
`"angular"`, but any changes in the parent scope will be reflected in the widget scope.
* `accessor` - Set up getter/setter function for the expression in the widget element
attribute to the widget scope. <br/> Given `<widget my-attr='name'>` and widget definition
of `scope: {myAttr:'prop'}`, and parent scope `{name:'angular'}` then widget scope
property `myAttr` will be a function such that `myAttr()` will return `"angular"` and
`myAttr('new value')` will update the parent scope `name` property. This is useful for
treating the element as a data-model for reading/writing.
* `expression` - Treat element attribute as an expression to be executed on the parent scope.
<br/>
Given `<widget my-attr='doSomething()'>` and widget definition of `scope:
{myAttr:'expression'}`, and parent scope `{doSomething:function() {}}` then calling the
widget scope function `myAttr` will execute the expression against the parent scope.
* `&` or `&attr` - provides a way to execute an expression in the context of the parent scope.
If no `attr` name is specified then the local name and attribute name are same.
Given `<widget my-attr="count = count + value">` and widget definition of
`scope: { localFn:'increment()' }`, then isolate scope property `localFn` will point to
a function wrapper for the `increment()` expression. Often it's desirable to pass data from
the isolate scope via an expression and to the parent scope, this can be done by passing a
map of local variable names and values into the expression wrapper fn. For example if the
expression is `increment(amount)` then we can specify the amount value by calling the
`localFn` as `localFn({amount: 22})`.
* `controller` - Controller constructor function. The controller is instantiated before the
pre-linking phase and it is shared with other directives if they request it by name (see
@ -369,32 +367,6 @@ compiler}. The attributes are:
* `^` - Look for the controller on parent elements as well.
* `inject` (object hash) - Specifies a way to inject bindings into a controller. Injection
definition is a hash of normalized element attribute names to their corresponding binding
strategy. Valid binding strategies are:
* `attribute` - inject attribute value. <br/>
Given `<widget my-attr='abc'>` and widget definition of `inject: {myAttr:'attribute'}`, then
`myAttr` will inject `"abc"`.
* `evaluate` - inject one time evaluation of expression stored in the attribute. <br/>
Given `<widget my-attr='name'>` and widget definition of `inject: {myAttr:'evaluate'}`, and
parent scope `{name:'angular'}` then `myAttr` will inject `"angular"`.
* `accessor` - inject a getter/setter function for the expression in the widget element
attribute to the widget scope. <br/>
Given `<widget my-attr='name'>` and widget definition of `inject: {myAttr:'prop'}`, and
parent scope `{name:'angular'}` then injecting `myAttr` will inject a function such
that `myAttr()` will return `"angular"` and `myAttr('new value')` will update the parent
scope `name` property. This is usefull for treating the element as a data-model for
reading/writing.
* `expression` - Inject expression function. <br/>
Given `<widget my-attr='doSomething()'>` and widget definition of
`inject: {myAttr:'expression'}`, and parent scope `{doSomething:function() {}}` then
injecting `myAttr` will inject a function which when called will execute the expression
against the parent scope.
* `restrict` - String of subset of `EACM` which restricts the directive to a specific directive
declaration style. If omitted directives are allowed on attributes only.
@ -649,9 +621,9 @@ Following is an example of building a reusable widget.
// This HTML will replace the zippy directive.
replace: true,
transclude: true,
scope: { zippyTitle:'bind' },
scope: { title:'@zippyTitle' },
template: '<div>' +
'<div class="title">{{zippyTitle}}</div>' +
'<div class="title">{{title}}</div>' +
'<div class="body" ng-transclude></div>' +
'</div>',
// The linking function will add behavior to the template

View file

@ -18,6 +18,9 @@
*/
var NON_ASSIGNABLE_MODEL_EXPRESSION = 'Non-assignable model expression: ';
/**
* @ngdoc function
* @name angular.module.ng.$compile
@ -225,47 +228,6 @@ function $CompileProvider($provide) {
function($injector, $interpolate, $exceptionHandler, $http, $templateCache, $parse,
$controller, $rootScope) {
var LOCAL_MODE = {
attribute: function(localName, mode, parentScope, scope, attr) {
scope[localName] = attr[localName];
},
evaluate: function(localName, mode, parentScope, scope, attr) {
scope[localName] = parentScope.$eval(attr[localName]);
},
bind: function(localName, mode, parentScope, scope, attr) {
var getter = $interpolate(attr[localName]);
scope.$watch(
function() { return getter(parentScope); },
function(v) { scope[localName] = v; }
);
},
accessor: function(localName, mode, parentScope, scope, attr) {
var getter = noop,
setter = noop,
exp = attr[localName];
if (exp) {
getter = $parse(exp);
setter = getter.assign || function() {
throw Error("Expression '" + exp + "' not assignable.");
};
}
scope[localName] = function(value) {
return arguments.length ? setter(parentScope, value) : getter(parentScope);
};
},
expression: function(localName, mode, parentScope, scope, attr) {
scope[localName] = function(locals) {
$parse(attr[localName])(parentScope, locals);
};
}
};
var Attributes = function(element, attr) {
this.$$element = element;
this.$attr = attr || {};
@ -746,9 +708,67 @@ function $CompileProvider($provide) {
$element = attrs.$$element;
if (newScopeDirective && isObject(newScopeDirective.scope)) {
forEach(newScopeDirective.scope, function(mode, name) {
(LOCAL_MODE[mode] || wrongMode)(name, mode,
scope.$parent || scope, scope, attrs);
var LOCAL_REGEXP = /^\s*([@=&])\s*(\w*)\s*$/;
var parentScope = scope.$parent || scope;
forEach(newScopeDirective.scope, function(definiton, scopeName) {
var match = definiton.match(LOCAL_REGEXP) || [],
attrName = match[2]|| scopeName,
mode = match[1], // @, =, or &
lastValue,
parentGet, parentSet;
switch (mode) {
case '@': {
attrs.$observe(attrName, function(value) {
scope[scopeName] = value;
});
attrs.$$observers[attrName].$$scope = parentScope;
break;
}
case '=': {
parentGet = $parse(attrs[attrName]);
parentSet = parentGet.assign || function() {
// reset the change, or we will throw this exception on every $digest
lastValue = scope[scopeName] = parentGet(parentScope);
throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + attrs[attrName] +
' (directive: ' + newScopeDirective.name + ')');
};
lastValue = scope[scopeName] = parentGet(parentScope);
scope.$watch(function() {
var parentValue = parentGet(parentScope);
if (parentValue !== scope[scopeName]) {
// we are out of sync and need to copy
if (parentValue !== lastValue) {
// parent changed and it has precedence
lastValue = scope[scopeName] = parentValue;
} else {
// if the parent can be assigned then do so
parentSet(parentScope, lastValue = scope[scopeName]);
}
}
return parentValue;
});
break;
}
case '&': {
parentGet = $parse(attrs[attrName]);
scope[scopeName] = function(locals) {
return parentGet(parentScope, locals);
}
break;
}
default: {
throw Error('Invalid isolate scope definition for directive ' +
newScopeDirective.name + ': ' + definiton);
}
}
});
}
@ -761,12 +781,6 @@ function $CompileProvider($provide) {
$transclude: boundTranscludeFn
};
forEach(directive.inject || {}, function(mode, name) {
(LOCAL_MODE[mode] || wrongMode)(name, mode,
newScopeDirective ? scope.$parent || scope : scope, locals, attrs);
});
controller = directive.controller;
if (controller == '@') {
controller = attrs[directive.name];
@ -1007,9 +1021,10 @@ function $CompileProvider($provide) {
attr[name] = undefined;
($$observers[name] || ($$observers[name] = [])).$$inter = true;
scope.$watch(interpolateFn, function(value) {
attr.$set(name, value);
});
(attr.$$observers && attr.$$observers[name].$$scope || scope).
$watch(interpolateFn, function(value) {
attr.$set(name, value);
});
})
});
}

View file

@ -857,8 +857,8 @@ var VALID_CLASS = 'ng-valid',
* </example>
*
*/
var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$element',
function($scope, $exceptionHandler, $attr, ngModel, $element) {
var NgModelController = ['$scope', '$exceptionHandler', '$attrs', '$element', '$parse',
function($scope, $exceptionHandler, $attr, $element, $parse) {
this.$viewValue = Number.NaN;
this.$modelValue = Number.NaN;
this.$parsers = [];
@ -870,6 +870,14 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e
this.$invalid = false;
this.$name = $attr.name;
var ngModelGet = $parse($attr.ngModel),
ngModelSet = ngModelGet.assign;
if (!ngModelSet) {
throw Error(NON_ASSIGNABLE_MODEL_EXPRESSION + $attr.ngModel +
' (' + startingTag($element) + ')');
}
/**
* @ngdoc function
* @name angular.module.ng.$compileProvider.directive.ngModel.NgModelController#$render
@ -974,7 +982,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e
if (this.$modelValue !== value) {
this.$modelValue = value;
ngModel(value);
ngModelSet($scope, value);
forEach(this.$viewChangeListeners, function(listener) {
try {
listener();
@ -987,9 +995,7 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e
// model -> value
var ctrl = this;
$scope.$watch(function() {
return ngModel();
}, function(value) {
$scope.$watch(ngModelGet, function(value) {
// ignore change from view
if (ctrl.$modelValue === value) return;
@ -1044,9 +1050,6 @@ var NgModelController = ['$scope', '$exceptionHandler', '$attrs', 'ngModel', '$e
*/
var ngModelDirective = function() {
return {
inject: {
ngModel: 'accessor'
},
require: ['ngModel', '^?form'],
controller: NgModelController,
link: function(scope, element, attr, ctrls) {

View file

@ -1,7 +1,7 @@
'use strict';
describe('$compile', function() {
var element, directive;
var element, directive, $compile, $rootScope;
beforeEach(module(provideLog, function($provide, $compileProvider){
element = null;
@ -54,8 +54,17 @@ describe('$compile', function() {
priority: -100, // even with negative priority we still should be able to stop descend
terminal: true
}));
return function(_$compile_, _$rootScope_) {
$rootScope = _$rootScope_;
$compile = _$compile_;
};
}));
function compile(html) {
element = angular.element(html);
$compile(element)($rootScope);
}
afterEach(function(){
dealoc(element);
@ -1633,105 +1642,166 @@ describe('$compile', function() {
});
describe('locals', function() {
it('should marshal to locals', function() {
module(function() {
directive('widget', function(log) {
return {
scope: {
attr: 'attribute',
prop: 'evaluate',
bind: 'bind',
assign: 'accessor',
read: 'accessor',
exp: 'expression',
nonExist: 'accessor',
nonExistExpr: 'expression'
},
link: function(scope, element, attrs) {
scope.nonExist(); // noop
scope.nonExist(123); // noop
scope.nonExistExpr(); // noop
scope.nonExistExpr(123); // noop
log(scope.attr);
log(scope.prop);
log(scope.assign());
log(scope.read());
log(scope.assign('ng'));
scope.exp({myState:'OK'});
expect(function() { scope.read(undefined); }).
toThrow("Expression ''D'' not assignable.");
scope.$watch('bind', log);
}
};
});
describe('isolated locals', function() {
var componentScope;
beforeEach(module(function() {
directive('myComponent', function() {
return {
scope: {
attr: '@',
attrAlias: '@attr',
ref: '=',
refAlias: '= ref',
expr: '&',
exprAlias: '&expr'
},
link: function(scope) {
componentScope = scope;
}
};
});
inject(function(log, $compile, $rootScope) {
$rootScope.myProp = 'B';
$rootScope.bi = {nd: 'C'};
$rootScope.name = 'C';
element = $compile(
'<div><div widget attr="A" prop="myProp" bind="{{bi.nd}}" assign="name" read="\'D\'" ' +
'exp="state=myState">{{bind}}</div></div>')
($rootScope);
expect(log).toEqual('A; B; C; D; ng');
expect($rootScope.name).toEqual('ng');
expect($rootScope.state).toEqual('OK');
log.reset();
$rootScope.$apply();
expect(element.text()).toEqual('C');
expect(log).toEqual('C');
$rootScope.bi.nd = 'c';
$rootScope.$apply();
expect(log).toEqual('C; c');
directive('badDeclaration', function() {
return {
scope: { attr: 'xxx' }
};
});
}));
describe('attribute', function() {
it('should copy simple attribute', inject(function() {
compile('<div><span my-component attr="some text">');
expect(componentScope.attr).toEqual(undefined);
expect(componentScope.attrAlias).toEqual(undefined);
$rootScope.$apply();
expect(componentScope.attr).toEqual('some text');
expect(componentScope.attrAlias).toEqual('some text');
expect(componentScope.attrAlias).toEqual(componentScope.attr);
}));
it('should update when interpolated attribute updates', inject(function() {
compile('<div><span my-component attr="hello {{name}}">');
expect(componentScope.attr).toEqual(undefined);
expect(componentScope.attrAlias).toEqual(undefined);
$rootScope.name = 'misko';
$rootScope.$apply();
expect(componentScope.attr).toEqual('hello misko');
expect(componentScope.attrAlias).toEqual('hello misko');
$rootScope.name = 'igor';
$rootScope.$apply();
expect(componentScope.attr).toEqual('hello igor');
expect(componentScope.attrAlias).toEqual('hello igor');
}));
});
describe('object reference', function() {
it('should update local when origin changes', inject(function() {
compile('<div><span my-component ref="name">');
expect(componentScope.ref).toBe(undefined);
expect(componentScope.refAlias).toBe(componentScope.ref);
$rootScope.name = 'misko';
$rootScope.$apply();
expect(componentScope.ref).toBe($rootScope.name);
expect(componentScope.refAlias).toBe($rootScope.name);
$rootScope.name = {};
$rootScope.$apply();
expect(componentScope.ref).toBe($rootScope.name);
expect(componentScope.refAlias).toBe($rootScope.name);
}));
it('should update local when origin changes', inject(function() {
compile('<div><span my-component ref="name">');
expect(componentScope.ref).toBe(undefined);
expect(componentScope.refAlias).toBe(componentScope.ref);
componentScope.ref = 'misko';
$rootScope.$apply();
expect($rootScope.name).toBe('misko');
expect(componentScope.ref).toBe('misko');
expect($rootScope.name).toBe(componentScope.ref);
expect(componentScope.refAlias).toBe(componentScope.ref);
componentScope.name = {};
$rootScope.$apply();
expect($rootScope.name).toBe(componentScope.ref);
expect(componentScope.refAlias).toBe(componentScope.ref);
}));
it('should update local when both change', inject(function() {
compile('<div><span my-component ref="name">');
$rootScope.name = {mark:123};
componentScope.ref = 'misko';
$rootScope.$apply();
expect($rootScope.name).toEqual({mark:123})
expect(componentScope.ref).toBe($rootScope.name);
expect(componentScope.refAlias).toBe($rootScope.name);
$rootScope.name = 'igor';
componentScope.ref = {};
$rootScope.$apply();
expect($rootScope.name).toEqual('igor')
expect(componentScope.ref).toBe($rootScope.name);
expect(componentScope.refAlias).toBe($rootScope.name);
}));
it('should complain on non assignable changes', inject(function() {
compile('<div><span my-component ref="\'hello \' + name">');
$rootScope.name = 'world';
$rootScope.$apply();
expect(componentScope.ref).toBe('hello world');
componentScope.ref = 'ignore me';
expect($rootScope.$apply).
toThrow("Non-assignable model expression: 'hello ' + name (directive: myComponent)");
expect(componentScope.ref).toBe('hello world');
// reset since the exception was rethrown which prevented phase clearing
$rootScope.$$phase = null;
$rootScope.name = 'misko';
$rootScope.$apply();
expect(componentScope.ref).toBe('hello misko');
}));
});
describe('executable expression', function() {
it('should allow expression execution with locals', inject(function() {
compile('<div><span my-component expr="count = count + offset">');
$rootScope.count = 2;
expect(typeof componentScope.expr).toBe('function');
expect(typeof componentScope.exprAlias).toBe('function');
expect(componentScope.expr({offset: 1})).toEqual(3);
expect($rootScope.count).toEqual(3);
expect(componentScope.exprAlias({offset: 10})).toEqual(13);
expect($rootScope.count).toEqual(13);
}));
});
it('should throw on unknown definition', inject(function() {
expect(function() {
compile('<div><span bad-declaration>');
}).toThrow('Invalid isolate scope definition for directive badDeclaration: xxx');
}));
});
describe('controller', function() {
it('should inject locals to controller', function() {
module(function() {
directive('widget', function(log) {
return {
controller: function(attr, prop, assign, read, exp){
log(attr);
log(prop);
log(assign());
log(read());
log(assign('ng'));
exp();
expect(function() { read(undefined); }).
toThrow("Expression ''D'' not assignable.");
this.result = 'OK';
},
inject: {
attr: 'attribute',
prop: 'evaluate',
assign: 'accessor',
read: 'accessor',
exp: 'expression'
},
link: function(scope, element, attrs, controller) {
log(controller.result);
}
};
});
});
inject(function(log, $compile, $rootScope) {
$rootScope.myProp = 'B';
$rootScope.bi = {nd: 'C'};
$rootScope.name = 'C';
element = $compile(
'<div><div widget attr="A" prop="myProp" bind="{{bi.nd}}" assign="name" read="\'D\'" ' +
'exp="state=\'OK\'">{{bind}}</div></div>')
($rootScope);
expect(log).toEqual('A; B; C; D; ng; OK');
expect($rootScope.name).toEqual('ng');
});
});
it('should get required controller', function() {
module(function() {
directive('main', function(log) {
@ -1986,11 +2056,11 @@ describe('$compile', function() {
module(function() {
directive('box', valueFn({
transclude: 'content',
scope: { name: 'evaluate', show: 'accessor' },
scope: { name: '=', show: '=' },
template: '<div><h1>Hello: {{name}}!</h1><div ng-transclude></div></div>',
link: function(scope, element) {
scope.$watch(
function() { return scope.show(); },
'show',
function(show) {
if (!show) {
element.find('div').find('div').remove();

View file

@ -4,7 +4,7 @@ describe('NgModelController', function() {
var ctrl, scope, ngModelAccessor, element, parentFormCtrl;
beforeEach(inject(function($rootScope, $controller) {
var attrs = {name: 'testAlias'};
var attrs = {name: 'testAlias', ngModel: 'value'};
parentFormCtrl = {
$setValidity: jasmine.createSpy('$setValidity'),
@ -17,12 +17,7 @@ describe('NgModelController', function() {
scope = $rootScope;
ngModelAccessor = jasmine.createSpy('ngModel accessor');
ctrl = $controller(NgModelController, {
$scope: scope, $element: element.find('input'), ngModel: ngModelAccessor, $attrs: attrs
});
// mock accessor (locals)
ngModelAccessor.andCallFake(function(val) {
if (isDefined(val)) scope.value = val;
return scope.value;
$scope: scope, $element: element.find('input'), $attrs: attrs
});
}));
@ -32,6 +27,26 @@ describe('NgModelController', function() {
});
it('should fail on non-assignable model binding', inject(function($controller) {
var exception;
try {
$controller(NgModelController, {
$scope: null,
$element: jqLite('<input ng-model="1+2">'),
$attrs: {
ngModel: '1+2'
}
});
} catch (e) {
exception = e;
}
expect(exception.message).
toMatch(/Non-assignable model expression: 1\+2 \(<input( value="")? ng-model="1\+2">\)/);
}));
it('should init the properties', function() {
expect(ctrl.$dirty).toBe(false);
expect(ctrl.$pristine).toBe(true);