Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

refactor($interpolate): optimize watched $interpolate functions performance #4556

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 88 additions & 27 deletions src/ng/interpolate.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,11 @@ function $InterpolateProvider() {
var startIndex,
endIndex,
index = 0,
parts = [],
length = text.length,
hasInterpolation = false,
fn,
fn = null,
exp,
concat = [];
parts = [];

while(index < length) {
if ( ((startIndex = text.indexOf(startSymbol, index)) != -1) &&
Expand All @@ -149,10 +148,9 @@ function $InterpolateProvider() {
}
}

if (!(length = parts.length)) {
if (!parts.length) {
// we added, nothing, must have been an empty string.
parts.push('');
length = 1;
}

// Concatenating expressions makes it hard to reason about whether some combination of
Expand All @@ -169,37 +167,101 @@ function $InterpolateProvider() {
}

if (!mustHaveExpression || hasInterpolation) {
concat.length = length;
fn = function(context) {
var concat = new Array(parts.length),
expressions = {};

forEach(parts, function(value, index) {
if (isFunction(value)) {
expressions[index] = value;
} else {
concat[index] = value;
}
});

// computes all the interpolations and returns the resulting string
// a specific index might already be computed (thanks to the scope's dirty-checking),
// and so its expression shouldn't be executed a 2nd time
// also populates the lastValues of custom watchers for internal dirty-checking
var getConcatValue = function(scope, computedIndex, computedValue, lastValues) {
try {
for(var i = 0, ii = length, part; i<ii; i++) {
if (typeof (part = parts[i]) == 'function') {
part = part(context);
if (trustedContext) {
part = $sce.getTrusted(trustedContext, part);
} else {
part = $sce.valueOf(part);
}
if (part === null || isUndefined(part)) {
part = '';
} else if (typeof part != 'string') {
part = toJson(part);
}
}
concat[i] = part;
}

forEach(expressions, function(expression, index) {
concat[index] = (index === computedIndex)
? computedValue
: getStringValue(expression(scope));

if (lastValues) lastValues[index] = concat[index];
});
return concat.join('');
}
catch(err) {

} catch(err) {
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}", text,
err.toString());
$exceptionHandler(newErr);
}
};

var getStringValue = function(value) {
value = trustedContext
? $sce.getTrusted(trustedContext, value)
: $sce.valueOf(value);

if (value == null) {
return '';
}
return isString(value) ? value : toJson(value);
};

fn = function(scope) {
return getConcatValue(scope /*, undefined, ...*/);
};
fn.exp = text;
fn.parts = parts;
return fn;

// watches each interpolation separately for performance
fn.$$beWatched = function(scope, origListener, objectEquality) {
var lastTextValue, lastValues = {}, watchersRm = [];

forEach(expressions, function(expression, index) {
watchersRm.push(
scope.$watch(watcherOf(expression), listenerOf(index), objectEquality));
});

function watcherOf(expression) {
return function interpolatedExprWatcher(scope) {
try {
return getStringValue(expression(scope));
} catch (err) {
var newErr = $interpolateMinErr('interr', "Can't interpolate: {0}\n{1}",
text, err.toString());
$exceptionHandler(newErr);
}
};
}

function listenerOf(index) {
return function interpolatedExprListener(value, oldValue) {
// we only invoke the origListener if the current value
// is not equal to the last computed value
// ex: if in `{{a}}-{{b}}` both values change in a digest, the listener of
// `a` gets invoked first, we compute the string and call the origListener
// and don't invoke it again when the listener of `b` gets triggered
// (unless the value of `b` changes again since the last computation!)
if (value !== lastValues[index]) {
var textValue = getConcatValue(scope, index, value, lastValues);
origListener.call(this, textValue,
value === oldValue ? textValue : lastTextValue, scope);
lastTextValue = textValue;
}
};
}

return function compositeWatchersRm() {
forEach(watchersRm, function(wRm){ wRm(); });
};
};
}
return fn;
}


Expand Down Expand Up @@ -239,4 +301,3 @@ function $InterpolateProvider() {
return $interpolate;
}];
}

3 changes: 3 additions & 0 deletions src/ng/rootScope.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,9 @@ function $RootScopeProvider(){
* @returns {function()} Returns a deregistration function for this listener.
*/
$watch: function(watchExp, listener, objectEquality) {
if (watchExp.$$beWatched) {
return watchExp.$$beWatched(this, listener, objectEquality, watchExp);
}
var scope = this,
get = compileToFn(watchExp, 'watch'),
array = scope.$$watchers,
Expand Down
85 changes: 80 additions & 5 deletions test/ng/interpolateSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@

describe('$interpolate', function() {

it('should return a function when there are no bindings and textOnly is undefined',
it('should return a function when there are no bindings and mustHaveExpression is undefined',
inject(function($interpolate) {
expect(typeof $interpolate('some text')).toBe('function');
}));


it('should return undefined when there are no bindings and textOnly is set to true',
it('should return null when there are no bindings and mustHaveExpression is set to true',
inject(function($interpolate) {
expect($interpolate('some text', true)).toBeUndefined();
expect($interpolate('some text', true)).toBeNull();
}));

it('should suppress falsy objects', inject(function($interpolate) {
Expand Down Expand Up @@ -70,7 +69,7 @@ describe('$interpolate', function() {
describe('interpolating in a trusted context', function() {
var sce;
beforeEach(function() {
function log() {};
function log() {}
var fakeLog = {log: log, warn: log, info: log, error: log};
module(function($provide, $sceProvider) {
$provide.value('$log', fakeLog);
Expand Down Expand Up @@ -274,4 +273,80 @@ describe('$interpolate', function() {
}));
});

describe('custom $$beWatched', function () {
it('should call the listener correctly when values change during digest',
inject(function ($rootScope, $interpolate) {
var count = 0, value;
$rootScope.$watch($interpolate('{{a}}-{{b}}'), function (_value) {
switch(++count) {
case 1:
case 2:
$rootScope.a++;
break;
case 3:
case 4:
$rootScope.b++;
break;
case 5:
value = _value;
break;
}
});
$rootScope.$apply("a=0;b=0");
expect(value).toBe("2-2");
expect(count).toBe(5);
}));

it('should call the listener correctly when the interpolation is watched multiple times',
inject(function ($rootScope, $interpolate) {
var interpolateFn = $interpolate('{{a}}-{{b}}'), count = 0;
$rootScope.$watch(interpolateFn, function(){
count++;
});
$rootScope.$watch(interpolateFn, function(){
count++;
});

$rootScope.$apply("a=0;b=0");
expect(count).toBe(2);

$rootScope.$apply("a=1;b=1");
expect(count).toBe(4);
}));

it('should call the listener only once per whole text change', inject(function ($rootScope, $interpolate) {
var count = 0, value;
$rootScope.$watch($interpolate("{{a}}-{{b}}"), function (_value){
count++;
value = _value;
});

$rootScope.$apply();
expect(count).toBe(1);
expect(value).toBe('-');

$rootScope.$apply('a=1;b=1');
expect(count).toBe(2);
expect(value).toBe('1-1');

// one changes
$rootScope.$apply('a=2');
expect(count).toBe(3);
expect(value).toBe('2-1');

$rootScope.$apply('b=2');
expect(count).toBe(4);
expect(value).toBe('2-2');

// both change
$rootScope.$apply('a=3;b=3');
expect(count).toBe(5);
expect(value).toBe('3-3');

// nothing changes
$rootScope.$apply();
expect(count).toBe(5);
expect(value).toBe('3-3');
}));
});
});