In-depth analysis of AngularJS dirty checks and in-depth analysis of angularjs
Start
The Angular dirty check has not been carefully studied before. It just says that Angular regularly performs periodic data checks to compare foreground and background data, so it is very performance-consuming.
This is a big mistake. I even spoke nonsense during the Sina front-end interview. It's a shame to think about it now! Without a deep understanding, it is really embarrassing to believe that.
Finally, rejection is taken for granted.
Misunderstanding correction
First, correct the mistakes. Angular does not trigger the Tibet check periodically.
Dirty check is triggered only when the UI event, ajax request, or timeout delay event occurs.
Why is it a dirty check? Check dirty data is a dirty check, and compare whether the UI and background data are consistent!
The following explanation:
$ Watch object.
Angular has a $ watch object for every data bound to the UI.
This object contains three parameters.
Watch = {name: '', // The data name getNewValue: function ($ scope) observed by the current watch object {// get the new value... return newValue;}, listener: function (newValue, oldValue) {// the operation to be performed when data changes ...}}
GetNewValue () can get the latest value on the current $ scope. The listener function gets the new value and old value and performs some operations.
The listener is usually empty when Angular is used. The listener is added explicitly only when the change event needs to be monitored.
Whenever we bind data to the UI, angular inserts a $ watch into your watchList.
For example:
<span>{{user}}</span><span>{{password}}</span>
This inserts two $ watch objects.
Then begin the dirty check.
Okay, let's put the dirty check first to see what happened before it.
Bidirectional data binding! Only by understanding Angular's two-way Data Binding can we thoroughly understand the dirty checking.
Bidirectional data binding
Angular implements bidirectional data binding. It is nothing more than interface operations that reflect data, and data changes can also be displayed on the interface.
UI-to-data changes are performed by callback operations such as UI events, ajax requests, and timeout, while the presentation of data to the interface is performed by dirty checks.
This is my misunderstanding.
Dirty check is triggered only when a UI event, ajax request, or timeout delay is triggered.
See the following example.
<div ng-controller="CounterCtrl"> <span ng-bind="counter"></span> <button ng-click="counter=counter+1">increase</button></div>
function CounterCtrl($scope) { $scope.counter = 1;}
Undoubtedly, every time I click the button, counter will + 1. Because of the click event, couter + 1 will be triggered, dirty check will be triggered, and the new value 2 will be returned to the interface.
This is a simple two-way data binding process.
But is it that simple ??
See the following code.
'use strict';var app = angular.module('app', []);app.directive('myclick', function() { return function(scope, element, attr) { element.on('click', function() { scope.data++; console.log(scope.data) }) }})app.controller('appController', function($scope) { $scope.data = 0;});
<div ng-app="app"> <div ng-controller="appController"> <span>{{data}}</span> <button myclick>click</button> </div> </div>
After clicking, there is no response.
Try adding scope. $ digest (); after console. log (scope. data?
Obviously, data is increased. What if $ apply () is used? Of course. (the difference between $ apply and $ digest will be accepted later)
Why?
If AngularJS is not available, how can we implement this similar function on our own?
<body> <button ng-click="increase">increase</button> <button ng-click="decrease">decrease</button> <span ng-bind="data"></span> <script src="app.js"></script></body>
window.onload = function() { 'use strict'; var scope = { increase: function() { this.data++; }, decrease: function decrease() { this.data--; }, data: 0 } function bind() { var list = document.querySelectorAll('[ng-click]'); for (var i = 0, l = list.length; i < l; i++) { list[i].onclick = (function(index) { return function() { var func = this.getAttribute('ng-click'); scope[func](scope); apply(); } })(i); } } // apply function apply() { var list = document.querySelectorAll('[ng-bind]'); for (var i = 0, l = list.length; i < l; i++) { var bindData = list[i].getAttribute('ng-bind'); list[i].innerHTML = scope[bindData]; } } bind(); apply();}
Test:
We can see that we didn't directly use the DOM onclick method, but instead made a ng-click, and then took out the function corresponding to the ng-click in the bind, the event handler bound to onclick. Why? Because the data has changed, but it has not been filled on the interface, we need to do some additional operations here.
In addition, because of the two-way binding mechanism, although the data value is updated in the DOM operation, it is not immediately reflected on the interface, but is reflected on the interface through apply, thus, separation of duties can be considered as a single responsibility model.
In the real Angular, ng-click encapsulates the click and calls the apply function once to present the data to the interface.
In the apply function of Angular, dirty check is performed first to check whether oldValue and newVlue are equal. If they are not equal, newValue is fed back to the interface, if the listener event is registered through $ watch, the event is called.
Advantages and disadvantages of dirty checking
After the above analysis, we can summarize:
- Simply put, a dirty check is to call $ apply () or $ digest () to present the latest values in the data on the interface.
- Every time the UI event changes, ajax and timeout will trigger $ apply ().
But now we have the following discussion?
Is it a good way to continuously trigger dirty checks?
Many people think that the performance consumption is very high, and it is not as good as the observer mode of setter and getter. But let's look at the example below.
<span>{{checkedItemsNumber}}</span>
function Ctrl($scope){ var list = []; $scope.checkedItemsNumber = 0; for(var i = 0;i<1000;i++){ list.push(false); } $scope.toggleChecked = function(flag){ for(var i = 0,l= list.length;i++){ list[i] = flag; $scope.checkedItemsNumber++; } }}
Under the dirty detection mechanism, this process is not under pressure and will wait until the loop execution ends, and then update checkedItemsNumber at a time and apply it to the interface. However, the setter-based mechanism is miserable, and each time the checkedItemsNumber is changed, it needs to be updated, so the performance will be extremely low.
Therefore, the two monitoring methods have their own advantages and disadvantages. The best way is to understand the differences in their use methods and consider the differences in their performance. In different business scenarios, avoid the usage that is most likely to cause performance bottlenecks.
Now that we know how to bind two-way data to the dirty checking trigger mechanism, how does the dirty checking implement internally?
Internal Implementation of dirty checking
First, construct the $ scope object,
function $scope = function(){}
Now, let's go back to the start $ watch.
We say that each data bound to the UI has a corresponding $ watch object, which will be pushed to the watchList.
It has two functions as attributes.
- GetNewValue () is also called a monitoring function. It is brave to get a prompt after the value changes and return a new value.
- The listener () listener function is used to respond to behavior changes during data changes.
There is also a string attribute
Name: name of the variable for the current watch
function $scope(){ this. $$watchList = [];}
In the Angular framework, the double dollar prefix $ indicates that the variable is considered as private and should not be called in external code.
Now we can define the $ watch method. It accepts two functions as parameters and stores them in the $ watchers array. We need to store these functions on each Scope instance, so we need to put them on the prototype of Scope:
$scope.prototype.$watch = function(name,getNewValue,listener){ var watch = { name:name, getNewValue : getNewValue, listener : listener }; this.$$watchList.push(watch);}
The other side is the $ digest function. It executes all listeners registered in the scope. Let's implement a simplified version of it, traverse all the listeners, and call their listening functions:
$scope.prototype.$digest = function(){ var list = this.$$watchList; for(var i = 0,l = list.length;i<l;i++){ list[i].listener(); }}
Now we can add listeners and run the dirty check.
var scope = new Scope();scope.$watch(function() { console.log("hey i have got newValue")}, function() { console.log("i am the listener");})scope.$watch(function() { console.log("hey i have got newValue 2")}, function() { console.log("i am the listener2");})scope.$disget();
The code will be hosted on github. The test file path is the same as the path in the command.
OK. Both listeners have been triggered.
These functions are useless. We need to check whether the specified value returned by getNewValue has actually changed and then call the listener function.
So, we need to get the latest value on the data every time on getNewValue (), so we need to get the current scope object
getNewValue = function(scope){ return scope[this.name];}
Is the general form of monitoring functions: Get some values from the scope and then return.
$ Digest is used to call this monitoring function and compare the difference between the returned value and the last returned value. If they are different, the listener is dirty and its listening function should be called.
To do this, $ digest needs to remember the value returned by each monitoring function last time. Now that we have created an object for each listener, we only need to store the value of the previous listener. The following is a new implementation of $ digest to detect changes to the value of each monitoring function:
$ Scope. prototype. $ digest = function () {var list = this. $ watchList; for (var I = 0, l = list. length; I ++) {var watch = list [I]; var newValue = watch. getNewValue (this); // present data on the first rendering page. var oldValue = watch. last; if (newValue! = OldValue) {watch. listener (newValue, oldValue) ;}watch. last = newValue ;}}
For each watch, we use getNewValue () and pass the scope instance to obtain the latest data value. Then compare it with the previous value. If it is different, call getListener and pass the new value and old value together. Finally, we set the last attribute to the new returned value, that is, the latest value.
This $ digest is called again, and the last value is undefined, so data will be presented once.
Okay. Let's see how this monitoring function runs.
Var scope = new $ scope (); scope. hello = 10; scope. $ watch ('hello', function (scope) {// Note: to understand this, this function is actually var newValue = watch. getNewValue (this); To call this method, this indicates the current listener watch, so you can get the name return scope [this. name]}, function (newValue, oldValue) {console. log ('newvalue: '+ newValue + '~~~~ '+' OldValue: '+ oldValue);}) scope. $ digest (); scope. hello = 10; scope. $ digest (); scope. hello = 20; scope. $ digest ();
Running result
We have implemented the essence of Angular scope: Add listeners and run them in digest.
You can also see several important performance features of Angular scope:
- Adding data to the scope itself does not incur performance discounts. If no listener is monitoring a property, it does not matter if it is not in scope. Angular does not traverse the attributes of the scope. it traverses the listener. Once the data is bound to the UI, a listener is added.
- $ Digest calls each getNewValue (). Therefore, it is best to pay attention to the number of listeners and the performance of each independent monitoring function or expression.
Sometimes you do not need to register so many Listener
Look at the above program:
$ Scope. prototype. $ digest = function () {var list = this. $ watchList; for (var I = 0, l = list. length; I ++) {var watch = list [I]; var newValue = watch. getNewValue (this); // present data on the first rendering page. var oldValue = watch. last; if (newValue! = OldValue) {watch. listener (newValue, oldValue) ;}watch. last = newValue ;}}
In this way, each listener watch must register a listener. However, in Angular applications, only a few listeners need to register listener.
Change $ scope. prototype. $ WPU and place an empty function here.
$scope.prototype.$watch = function(name,getNewValue,listener){ var watch = { name:name, getNewValue : getNewValue, listener : listener || function(){} }; this.$$watchList.push(watch);}
It seems that we have initially understood the principle of dirty checking, but we have ignored an important issue.
Two listeners have been registered, and the listener of the second listener has changed the value of the data corresponding to the first listener. Will this be detected?
See the following example.
var scope = new $scope();scope.first = 10;scope.second = 1;scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first = 8; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$digest();console.log(scope.first);console.log(scope.second);
As you can see, the value is 8, 1, and has changed, but the value on the interface has not changed.
Fix this issue now.
Continue Digest when data is dirty
We need to change digest so that it can traverse all listeners until the monitored value stops changing.
First, we change the current $ digest function to $ digestOnce, which runs all the listeners once and returns a Boolean value indicating whether there are any changes.
$ Scope. prototype. $ digestOnce = function () {var dirty; var list = this. $ watchList; for (var I = 0, l = list. length; I <l; I ++) {var watch = list [I]; var newValue = watch. getNewValue (this. name); var oldValue = watch. last; if (newValue! = OldValue) {watch. listener (newValue, oldValue); // because of the listener operation, the data that has been checked may become dirty = true;} watch. last = newValue; return dirty ;}};
Then, we re-define $ digest, which runs as an "outer loop". When a change occurs, call $ digestOnce:
$scope.prototype.$digest = function() { var dirty = true; while(dirty) { dirty = this.$$digestOnce(); } };
$ Digest now runs each listener at least once. If the monitoring value is changed after the first running, it is marked as dirty, and all listeners run for the second time. This will continue to run until all monitoring values remain unchanged, and the overall situation has stabilized.
In Angular scope, there is not really a function called $ digestOnce. On the contrary, the digest loop is included in $ digest. Our goal is definition rather than performance, so we encapsulate the inner loop into a function.
Test
var scope = new $scope();scope.first = 10;scope.second = 1;scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first = 8; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$digest();console.log(scope.first);console.log(scope.second);
We can see that all the data on the interface is up-to-date.
Now we can have another important understanding of Angular listeners: they may be executed multiple times in a single digest. This is why it is often said that the listener should be idempotent: A listener should have no boundary effect, or the boundary effect should only occur for a limited number of times. For example, if a monitoring function triggers an Ajax request, you cannot determine how many requests your application sends.
What if two listeners change cyclically? As shown in the following figure:
var scope = new $scope();scope.first = 10;scope.second = 1;scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.second ++; })scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first ++; })
Then, the dirty check will not stop and keep repeating. How can this problem be solved?
More stable $ digest
What we need to do is to control the running of digest within an acceptable number of iterations. If the scope is still changing after so many times, let it go and announce that it will never be stable. At this point, we will throw an exception, because no matter the status of the scope changes, it is unlikely to be the result that the user wants.
The maximum value of an iteration is called TTL (short for Time To Live ). The default value is 10, which may be a little small (we just ran this digest for 100,000 times !), But remember this is a performance-sensitive place, because digest is often executed and each digest runs all the listeners. The user is unlikely to create more than 10 trace listeners.
Let's continue and add a loop counter to the outer digest loop. If TTL is reached, an exception is thrown:
$ Scope. prototype. $ digest = function () {var dirty = true; var checkTimes = 0; while (dirty) {dirty = this. $ digestOnce (); checkTimes ++; if (checkTimes> 10 & dirty) {throw new Error ("detected more than 10 times"); console. log ("123 ");}};};
Test
var scope = new $scope();scope.first = 1;scope.second = 10;scope.$watch('first', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.second++; console.log('first: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$watch('second', function(scope) { return scope[this.name] }, function(newValue, oldValue) { scope.first++; console.log('second: newValue:' + newValue + '~~~~' + 'oldValue:' + oldValue); })scope.$digest();
Now, the principles of Angular dirty checking and bidirectional data binding are introduced here. Although it is far from the real Angular, the principle can be basically explained. I hope it will be helpful for everyone's learning, and I hope you can support the house of helping customers more.