Это четвертая часть в серии из пяти статей, в которой рассматриваются распространенные ошибки AngularJS. Напомним, что пять основных ошибок, которые я вижу, делают люди:
- Сильная зависимость от $ scope (без использования контроллера как)
- Злоупотребляя $ watch
- Чрезмерное использование трансляции $ и $ emit
- Взлом DOM
- Неспособность проверить
В предыдущих статьях я рассмотрел некоторые нюансы, касающиеся контроллеров и того, как они взаимодействуют друг с другом и предоставляют информацию для привязки данных. В этой статье я расскажу о важности привязки данных и расскажу, почему важно избегать взлома DOM при написании приложения на Angular.
Взлом DOM
В конце концов, веб-приложения о DOM. Вы можете быть удивлены, узнав, что одна из самых популярных публикаций за все время в этом блоге — это короткая статья о простом взломе IE 6.0, который я опубликовал почти пять лет назад. Я также не стесняюсь признать, что в то время я действительно не понимал JavaScript, о чем свидетельствует «решение» (оно работало, но не обязательно было лучшим способом приблизиться к нему), и посчитал неудобством то, что я просто пришлось обойти, чтобы веб-приложения работали.
In fact, shortly after that post I did a proof of concept for an emerging technology (at the time) called Silverlight and ended up converting to it. To make a short story even shorter, there are many reasons why Silverlight made sense at the time and although it is no longer the main technology I use, I do consider Angular to be the modern HTML5 answer to Silverlight and the MVVM pattern.
The two biggest benefits I feel data-binding provides are:
- A declarative UI that encourages testability
- A designer/developer workflow
The second is really the result of the first. Let’s take a step back. What do I mean by declarative UI? Consider for a moment this code:
var value = window.document.forms["myForm"]["name"].value; if (value === null || value == '') { alert('Name is required.'); }
Now take a look at this solution instead (note the “required” attribute:
<input id="name" name="name" required placeholder="Enter your name"/>
Answer this: which one is easier to understand, even by a non-developer? And which one scales better – in other words, when there are more fields that are required, which solution is easier to apply to the new fields as they are introduced?
The declarative approach provides building blocks that can be placed in mark-up. The imperative approach allows logical code that requires a deeper understanding. Imperative code may enable more complex manipulation and make sense for business logic, but in my opinion you should declare as much of your UI as possible to make it designer-friendly. That’s the second point: by having a nice separation between the UI and your more complex back-end logic, your designer can literally work on the UI and manipulate it in various ways independent of your programming.
Fortunately, Angular provides a solution that allows you to encapsulate imperative logic in a declarative directive. Before I explore that further, however, I need to address the “traditional” method for imperatively interacting with the DOM: jQuery.
The jQuery Factor
I’ve been in code bases with controllers that look something like this:
function Controller($scope) { $scope.name = ''; $scope.nameError = false; $scope.$watch('name', function() { if ($scope.name === null || $scope.name == '') { $scope.nameError = true; $('#name').focus(); } }); }
Although this may work, my next post is going to cover testing and I think anyone would be challenged to test this piece of code without spinning up a browser page that has an input field with an identifier of “name.” That’s a lot of dependencies and tests are supposed to be easy to set up and run!
Now before I continue, let me make it clear I am a huge fan of jQuery. In fact, Angular will automatically fall through to use jQuery when it is present. When it is not, Angular provides it’s own lightweight version called jqLite. Just to be clear, however, I think the biggest benefit of jQuery is this:
jQuery is a tool for normalizing the DOM.
What do I mean by this? Just take a look at this comparison of web browsers and you’ll find things aren’t as standard as they may seem. Instead of having a lot of logic to detect which browser you are running in and use the appropriate APIs, jQuery normalizes this for you. You get a consistent interface to interact with the DOM and let jQuery worry about the implementation nuances across browsers.
In fact, people often ask me if Angular means “no jQuery.” Although Angular can greatly reduce the amount of jQuery used in an app, sometimes jQuery is still the right answer when heavy DOM manipulation is required. I just want to make sure it happens in the right place. Getting back to Angular, I have three very simple rules when it comes to interacting with the DOM, whether I’m using jQuery or jqLite or anything else. The rules are:
- Any imperative DOM manipulation must happen inside a directive
- Use services to mediate between controllers and directives or directives and other directives
- Services never depend on directives
That’s it. Even though this is older code, if you look at my 6502 emulator written in TypeScript and Angular, you’ll find a graphics display and a console (to see it in action, click Load to load a source, Compile to build it, and Run to execute it). The main CPU, however, never references the DOM and is ignorant of how it renders. In fact, whenever a byte is set in an address range that represents the display, this code is executed:
Cpu.prototype.poke = function (address, value) { this.memory[address & Constants.Memory.Max] = value & Constants.Memory.ByteMask; if (address >= Constants.Display.DisplayStart && address <= Constants.Display.DisplayStart + Constants.Display.Max) { this.displayService.draw(address, value); } };
Notice it simply passes the information to the service. The service is also testable in isolation because there is no dependency directly on a directive or the DOM. You can view the source here. So what happens? The directive takes a dependency on the service, hooks into the callback and renders the pixels using rectangles in SVG as you can see here.
There are several advantages to this approach. One is testing that I’ll cover in the next post. Another is stability. The more you rely on specific ids or specific types of DOM elements, the less stable your code is. For example, let’s assume I decided that SVG was the wrong way to render the display and wanted to use WebGL or something different. In this example, the only code I need to change is inside of the display directive. The service and application remain the same. I call this “refactoring containment” because the clean separation is like a bulkhead keeping you from having to change massive portions of the codebase or regression test your entire app just because you are tweaking the UI.
Here is a conceptual view of how your application might interact with the DOM – note the key interactions are either directly via data-binding or indirectly through the service/directive chain with jQuery thrown in where it makes sense to normalize the DOM.
To better illustrate this I’ll share a few more practical examples. Let’s take the initial comparison between imperative and declarative. How do you set the focus on an element declaratively? One approach would be to consider the focus a “state.” When a field is in an invalid state, for example, you’ll want to set the focus so the user can easily correct it. The app should only care about the state, and the directive can take care of the focus. Here’s an idea for the directive:
app.directive('giveFocus', ['$timeout', function ($timeout) { return { restrict: 'A', replace: false, scope: { binding: '=giveFocus' }, link: function (scope, element) { scope.$watch('binding', function () { var giveFocus = !!scope.binding; if (giveFocus) { $timeout(function () { element[0].focus(); }, 0); } }); } }; }]);
Place the directive on the element you want to have focus, and data-bind it to whatever property should trigger the focus. The timeout simply allows the current digest loop to finish before the focus is set.
Note: some of you may be lining up outside of my door waving pitchforks and throwing rotten eggs because I used $watch after writing an entire post about avoiding $watch. This is one of the cases where I believe $watch makes sense, because the isolate scope is such an intrinsic component of a directive.
Here’s an example of how you might use it:
<input type="text" id="name" give-focus="ctrl.invalidName" />
Now for an example with a service. It is quite common for your app to need to know the current size it is running in. You can only do so much with media queries and CSS. For example, let’s say you are rendering a grid with server-size paging. How do you set the page size? A common approach is to simply fix it to a hard-coded value, but if you know the height of a row you can easily accommodate the device and resize the grid accordingly.
Here’s a size service that simply keeps track of the height and width of the window you are concerned with. Your app will watch the values to make adjustments as needed.
function SizeService() { this.width = 0; this.height = 0; } app.service('sizeService', SizeService);
Here’s the size directive. It hooks into the resize event of the browser window and recalculates the size of the element it is bound to.
app.directive('bindSize', ['sizeService', '$window', '$timeout', function (ss, w, to) { return { restrict: 'A', replace: false, link: function (scope, element) { function bindSize() { scope.$apply(function () { ss.width = element[0].clientWidth; ss.height = element[0].clientHeight; }); } w.onresize = bindSize; to(bindSize, 0); } }; }]);
To use it, simply place the directive on the element you want to track the size of:
<div ng-app="myApp" ng-controller="ctrl as ctrl" bind-size="">
You can see a combined demo of the focus directive and size directive here. (If you want to support multiple elements, just pass in an identifier on the bind-size and change the service to track an indexed array of widths and heights).
Keep in mind you don’t necessarily have to use services just to mediate between controllers and directives. Sometimes the directives might use a service to communicate with each other! For the size example, it might be more practical to have another directive use the size service to set the page size on the grid, so the controller is never involved (or just queries the page size from the service, rather than trying to do any computation itself).
Don’t think you have to build these for yourself. Angular already has quite a few built-in features to help separate DOM concerns from logic concerns. For example, you can inject an abstraction of the window object using $window. Using this approach allows you to mock and test it (i.e. $window.alert vs. the hard-coded window.alert).
For class manipulation check out my Angular health app. I use the BMI value to color code the tile based on whether the individual is in a healthy range or not. To swap the class I use the built-in ng-class directive with a filter as you can see here.
Modern applications don’t just focus on look and feel, but the entire user experience. That means motion study and animations where it makes sense for transitions. Although I risk exposing the true reasons why I am a developer and not a designer, the Angular Tips and Tricks app uses the ngAnimate module with animate.css to create transitions. The most egregious examples are in the controller as piece.
The key is that the animations are declared in HTML and automatically trigger based on state changes and transitions. The services and controllers in the app are completely ignorant of how the views are implemented. This allows me to test those pieces and even develop them completely independently of the designer. The designer can literally add or remove animations and tweak how those animations work without ever stepping on my toes even if we are working on the same area at the same time!
To recap, there are just three simple rules you need to follow to avoid hacking the DOM and create truly scalable, maintainable, testable, and fun to use Angular applications:
- Any imperative DOM manipulation must happen inside a directive
- Use services to mediate between controllers and directives or directives and other directives
- Services never depend on directives
That’s it. I know I’ve mentioned testing several times, and I realize even today there are some shops that question its value and consider it to be overhead. Not only do I believe in the value of testing, I think it is so important that it makes my top five mistakes because too many Angular developers are missing out on one of the framework’s biggest benefits. Testing will be the topic of my next post.